mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-12 18:06:11 +02:00
Keep the no-project action first and available during project search, while preserving matching-project keyboard selection. Add focused regression coverage for ordering, projectless submission, and filtered Enter behavior. Co-Authored-By: Paperclip <noreply@paperclip.ing>
1939 lines
77 KiB
TypeScript
1939 lines
77 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import type { ComponentProps, ReactNode } from "react";
|
|
import { flushSync } from "react-dom";
|
|
import { createRoot } from "react-dom/client";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { queryKeys } from "../lib/queryKeys";
|
|
import { trackRecentAssignee, trackRecentAssigneeUser } from "../lib/recent-assignees";
|
|
import { getLastProjectId, trackRecentProject } from "../lib/recent-projects";
|
|
import { rememberComposerEffort } from "../lib/recent-composer-effort";
|
|
import { NewIssueDialog } from "./NewIssueDialog";
|
|
|
|
const dialogState = vi.hoisted(() => ({
|
|
newIssueOpen: true,
|
|
newIssueDefaults: {} as Record<string, unknown>,
|
|
closeNewIssue: vi.fn(),
|
|
}));
|
|
|
|
const dialogContentState = vi.hoisted(() => ({
|
|
onEscapeKeyDown: null as null | ((event: KeyboardEvent) => void),
|
|
onPointerDownOutside: null as null | ((event: {
|
|
detail: { originalEvent: { target: EventTarget | null } };
|
|
preventDefault: () => void;
|
|
}) => void),
|
|
}));
|
|
|
|
const companyState = vi.hoisted(() => ({
|
|
companies: [
|
|
{
|
|
id: "company-1",
|
|
name: "Paperclip",
|
|
status: "active",
|
|
issuePrefix: "PAP",
|
|
},
|
|
],
|
|
selectedCompanyId: "company-1",
|
|
selectedCompany: {
|
|
id: "company-1",
|
|
name: "Paperclip",
|
|
status: "active",
|
|
issuePrefix: "PAP",
|
|
},
|
|
}));
|
|
|
|
const toastState = vi.hoisted(() => ({
|
|
pushToast: vi.fn(),
|
|
}));
|
|
const navigateMock = vi.hoisted(() => vi.fn());
|
|
|
|
const mockIssuesApi = vi.hoisted(() => ({
|
|
list: vi.fn(),
|
|
create: vi.fn(),
|
|
upsertDocument: vi.fn(),
|
|
uploadAttachment: vi.fn(),
|
|
}));
|
|
|
|
const mockExecutionWorkspacesApi = vi.hoisted(() => ({
|
|
list: vi.fn(),
|
|
listSummaries: vi.fn(),
|
|
}));
|
|
|
|
const mockProjectsApi = vi.hoisted(() => ({
|
|
list: vi.fn(),
|
|
}));
|
|
|
|
const mockAgentsApi = vi.hoisted(() => ({
|
|
list: vi.fn(),
|
|
adapterModels: vi.fn(),
|
|
}));
|
|
|
|
const mockAuthApi = vi.hoisted(() => ({
|
|
getSession: vi.fn(),
|
|
}));
|
|
|
|
const mockAssetsApi = vi.hoisted(() => ({
|
|
uploadImage: vi.fn(),
|
|
}));
|
|
|
|
const mockInstanceSettingsApi = vi.hoisted(() => ({
|
|
getExperimental: vi.fn(),
|
|
}));
|
|
const mockMissingUserSecretsBannerRender = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("../context/DialogContext", () => ({
|
|
useDialog: () => dialogState,
|
|
}));
|
|
|
|
vi.mock("../context/SidebarContext", () => ({ useSidebar: () => ({ isMobile: false }) }));
|
|
|
|
vi.mock("../context/CompanyContext", () => ({
|
|
useCompany: () => companyState,
|
|
}));
|
|
|
|
vi.mock("../context/ToastContext", () => ({
|
|
useToastActions: () => toastState,
|
|
}));
|
|
|
|
vi.mock("../lib/router", async (importOriginal) => ({
|
|
...await importOriginal<typeof import("../lib/router")>(),
|
|
useNavigate: () => navigateMock,
|
|
}));
|
|
|
|
vi.mock("../api/issues", () => ({
|
|
issuesApi: mockIssuesApi,
|
|
}));
|
|
|
|
vi.mock("../api/execution-workspaces", () => ({
|
|
executionWorkspacesApi: mockExecutionWorkspacesApi,
|
|
}));
|
|
|
|
vi.mock("../api/projects", () => ({
|
|
projectsApi: mockProjectsApi,
|
|
}));
|
|
|
|
vi.mock("../api/agents", () => ({
|
|
agentsApi: mockAgentsApi,
|
|
}));
|
|
|
|
vi.mock("../api/auth", () => ({
|
|
authApi: mockAuthApi,
|
|
}));
|
|
|
|
vi.mock("../api/assets", () => ({
|
|
assetsApi: mockAssetsApi,
|
|
}));
|
|
|
|
vi.mock("../api/instanceSettings", () => ({
|
|
instanceSettingsApi: mockInstanceSettingsApi,
|
|
}));
|
|
|
|
vi.mock("../pages/secrets/MissingUserSecretsBanner", async () => {
|
|
const React = await import("react");
|
|
return {
|
|
MissingUserSecretsBanner: (props: { definitionKeys?: string[] }) => {
|
|
mockMissingUserSecretsBannerRender(props);
|
|
return React.createElement(
|
|
"div",
|
|
{ "data-testid": "missing-user-secrets-banner" },
|
|
props.definitionKeys?.join(",") ?? "",
|
|
);
|
|
},
|
|
};
|
|
});
|
|
|
|
vi.mock("../hooks/useProjectOrder", () => ({
|
|
useProjectOrder: ({ projects }: { projects: unknown[] }) => ({
|
|
orderedProjects: projects,
|
|
}),
|
|
}));
|
|
|
|
vi.mock("../api/access", () => ({
|
|
accessApi: { listUserDirectory: async () => ({ users: [] }) },
|
|
}));
|
|
|
|
vi.mock("../lib/assignees", () => ({
|
|
assigneeValueFromSelection: ({
|
|
assigneeAgentId,
|
|
assigneeUserId,
|
|
}: {
|
|
assigneeAgentId?: string;
|
|
assigneeUserId?: string;
|
|
}) => assigneeAgentId ? `agent:${assigneeAgentId}` : assigneeUserId ? `user:${assigneeUserId}` : "",
|
|
currentUserAssigneeOption: (id: string | null) => id ? [{ id: `user:${id}`, label: "Me" }] : [],
|
|
parseAssigneeValue: (value: string) => ({
|
|
assigneeAgentId: value.startsWith("agent:") ? value.slice("agent:".length) : null,
|
|
assigneeUserId: value.startsWith("user:") ? value.slice("user:".length) : null,
|
|
}),
|
|
}));
|
|
|
|
vi.mock("./MarkdownEditor", async () => {
|
|
const React = await import("react");
|
|
return {
|
|
MarkdownEditor: React.forwardRef<
|
|
{ focus: () => void },
|
|
{ value: string; onChange?: (value: string) => void; placeholder?: string }
|
|
>(function MarkdownEditorMock({ value, onChange, placeholder }, ref) {
|
|
React.useImperativeHandle(ref, () => ({
|
|
focus: () => undefined,
|
|
}));
|
|
return (
|
|
<textarea
|
|
aria-label={placeholder ?? "Description"}
|
|
value={value}
|
|
onChange={(event) => onChange?.(event.target.value)}
|
|
/>
|
|
);
|
|
}),
|
|
};
|
|
});
|
|
|
|
vi.mock("./InlineEntitySelector", async () => {
|
|
const React = await import("react");
|
|
return {
|
|
InlineEntitySelector: React.forwardRef<
|
|
HTMLButtonElement,
|
|
{
|
|
value: string;
|
|
placeholder?: string;
|
|
className?: string;
|
|
triggerDataSlot?: string;
|
|
noneAtTop?: boolean;
|
|
options?: { id: string; label: string }[];
|
|
onChange?: (id: string) => void;
|
|
renderTriggerValue?: (option: { id: string; label: string } | null) => ReactNode;
|
|
}
|
|
>(function InlineEntitySelectorMock({ value, placeholder, className, triggerDataSlot, noneAtTop, renderTriggerValue, options = [], onChange }, ref) {
|
|
return (
|
|
<button ref={ref} type="button" className={className} data-slot={triggerDataSlot} data-none-at-top={noneAtTop || undefined} onClick={() => onChange?.(options[options.findIndex((option) => option.id === value) + 1]?.id ?? "")}>
|
|
{(renderTriggerValue?.(value ? { id: value, label: value } : null) ?? value) || placeholder}
|
|
</button>
|
|
);
|
|
}),
|
|
};
|
|
});
|
|
|
|
vi.mock("./AgentIconPicker", () => ({
|
|
AgentIcon: () => null,
|
|
}));
|
|
|
|
vi.mock("@/components/ui/dialog", () => ({
|
|
DialogTitle: ({ children, ...props }: ComponentProps<"h2">) => <h2 {...props}>{children}</h2>,
|
|
Dialog: ({ open, children }: { open: boolean; children: ReactNode }) => (open ? <div>{children}</div> : null),
|
|
DialogContent: ({
|
|
children,
|
|
showCloseButton: _showCloseButton,
|
|
onEscapeKeyDown,
|
|
onPointerDownOutside,
|
|
...props
|
|
}: ComponentProps<"div"> & {
|
|
showCloseButton?: boolean;
|
|
onEscapeKeyDown?: (event: KeyboardEvent) => void;
|
|
onPointerDownOutside?: (event: unknown) => void;
|
|
}) => {
|
|
dialogContentState.onEscapeKeyDown = onEscapeKeyDown ?? null;
|
|
dialogContentState.onPointerDownOutside = onPointerDownOutside as typeof dialogContentState.onPointerDownOutside;
|
|
return <div {...props}>{children}</div>;
|
|
},
|
|
}));
|
|
|
|
vi.mock("@/components/ui/button", () => ({
|
|
Button: ({ children, onClick, type = "button", ...props }: ComponentProps<"button">) => (
|
|
<button type={type} onClick={onClick} {...props}>{children}</button>
|
|
),
|
|
}));
|
|
|
|
vi.mock("@/components/ui/toggle-switch", () => ({
|
|
ToggleSwitch: ({ checked, onCheckedChange }: { checked: boolean; onCheckedChange: () => void }) => (
|
|
<button type="button" aria-pressed={checked} onClick={onCheckedChange}>toggle</button>
|
|
),
|
|
}));
|
|
|
|
vi.mock("@/components/ui/dropdown-menu", () => ({
|
|
DropdownMenu: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
|
DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
|
|
DropdownMenuContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
|
DropdownMenuItem: ({ children, onSelect, ...props }: Omit<ComponentProps<"button">, "onSelect"> & { onSelect?: () => void }) => <button {...props} onClick={onSelect}>{children}</button>,
|
|
}));
|
|
|
|
vi.mock("@/components/ui/popover", () => ({
|
|
Popover: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
|
PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
|
|
PopoverAnchor: ({ children }: { children: ReactNode }) => <>{children}</>,
|
|
PopoverContent: ({ children, disablePortal }: { children: ReactNode; disablePortal?: boolean }) => (
|
|
<div data-disable-portal={String(Boolean(disablePortal))}>{children}</div>
|
|
),
|
|
}));
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
function act(callback: () => void | Promise<void>): void | Promise<void> {
|
|
let result: unknown;
|
|
flushSync(() => {
|
|
result = callback();
|
|
});
|
|
return result && typeof (result as Promise<void>).then === "function"
|
|
? (result as Promise<void>).then(() => undefined)
|
|
: undefined;
|
|
}
|
|
|
|
async function flush() {
|
|
await act(async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
});
|
|
}
|
|
|
|
async function typeTextareaValue(textarea: HTMLTextAreaElement | HTMLInputElement, value: string) {
|
|
await act(async () => {
|
|
const valueSetter = Object.getOwnPropertyDescriptor(
|
|
textarea instanceof HTMLInputElement ? window.HTMLInputElement.prototype : window.HTMLTextAreaElement.prototype,
|
|
"value",
|
|
)?.set;
|
|
valueSetter?.call(textarea, value);
|
|
textarea.dispatchEvent(
|
|
new InputEvent("input", {
|
|
bubbles: true,
|
|
data: value,
|
|
inputType: "insertText",
|
|
}),
|
|
);
|
|
textarea.dispatchEvent(new Event("change", { bubbles: true }));
|
|
});
|
|
await flush();
|
|
}
|
|
|
|
async function waitForAssertion(assertion: () => void, attempts = 20) {
|
|
let lastError: unknown;
|
|
|
|
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
try {
|
|
assertion();
|
|
return;
|
|
} catch (error) {
|
|
lastError = error;
|
|
await flush();
|
|
}
|
|
}
|
|
|
|
throw lastError;
|
|
}
|
|
|
|
function renderDialog(container: HTMLDivElement, hiddenSettings: string[] = []) {
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
mutations: { retry: false },
|
|
},
|
|
});
|
|
queryClient.setQueryData(queryKeys.health, { hiddenSettings });
|
|
const root = createRoot(container);
|
|
act(() => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<NewIssueDialog />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
return { root, queryClient };
|
|
}
|
|
|
|
describe("NewIssueDialog", () => {
|
|
let container: HTMLDivElement;
|
|
let originalResizeObserver: typeof ResizeObserver | undefined;
|
|
let originalVisualViewportDescriptor: PropertyDescriptor | undefined;
|
|
let originalInnerHeightDescriptor: PropertyDescriptor | undefined;
|
|
|
|
beforeEach(() => {
|
|
vi.useRealTimers();
|
|
originalResizeObserver = globalThis.ResizeObserver;
|
|
originalVisualViewportDescriptor = Object.getOwnPropertyDescriptor(window, "visualViewport");
|
|
originalInnerHeightDescriptor = Object.getOwnPropertyDescriptor(window, "innerHeight");
|
|
globalThis.ResizeObserver = class ResizeObserver {
|
|
observe() {}
|
|
unobserve() {}
|
|
disconnect() {}
|
|
};
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
dialogState.newIssueOpen = true;
|
|
dialogState.newIssueDefaults = {};
|
|
dialogState.closeNewIssue.mockReset();
|
|
dialogContentState.onEscapeKeyDown = null;
|
|
dialogContentState.onPointerDownOutside = null;
|
|
toastState.pushToast.mockReset();
|
|
navigateMock.mockReset();
|
|
mockIssuesApi.create.mockReset();
|
|
mockIssuesApi.list.mockReset().mockResolvedValue([]);
|
|
mockIssuesApi.upsertDocument.mockReset();
|
|
mockIssuesApi.uploadAttachment.mockReset();
|
|
mockExecutionWorkspacesApi.list.mockReset();
|
|
mockExecutionWorkspacesApi.listSummaries.mockReset();
|
|
mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([]);
|
|
mockProjectsApi.list.mockResolvedValue([
|
|
{
|
|
id: "project-1",
|
|
name: "Alpha",
|
|
description: null,
|
|
archivedAt: null,
|
|
color: "#445566",
|
|
},
|
|
]);
|
|
mockAgentsApi.list.mockResolvedValue([]);
|
|
mockAgentsApi.adapterModels.mockResolvedValue([]);
|
|
mockAuthApi.getSession.mockResolvedValue({ user: { id: "user-1" } });
|
|
mockAssetsApi.uploadImage.mockResolvedValue({ contentPath: "/uploads/asset.png" });
|
|
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
|
|
mockMissingUserSecretsBannerRender.mockReset();
|
|
localStorage.clear();
|
|
mockIssuesApi.create.mockResolvedValue({
|
|
id: "issue-2",
|
|
companyId: "company-1",
|
|
identifier: "PAP-2",
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
globalThis.ResizeObserver = originalResizeObserver!;
|
|
if (originalVisualViewportDescriptor) {
|
|
Object.defineProperty(window, "visualViewport", originalVisualViewportDescriptor);
|
|
} else {
|
|
Reflect.deleteProperty(window, "visualViewport");
|
|
}
|
|
if (originalInnerHeightDescriptor) {
|
|
Object.defineProperty(window, "innerHeight", originalInnerHeightDescriptor);
|
|
} else {
|
|
Reflect.deleteProperty(window, "innerHeight");
|
|
}
|
|
document.body.innerHTML = "";
|
|
});
|
|
|
|
const defaultAgents = [
|
|
{ id: "worker", name: "Worker", role: "engineer", status: "idle", adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: {} },
|
|
{ id: "ceo", name: "CEO", role: "ceo", status: "idle", adapterType: "claude_local", adapterConfig: {}, runtimeConfig: {}, permissions: {} },
|
|
];
|
|
|
|
it.each([
|
|
{ remembered: "project-1", expected: "project-1" },
|
|
{ remembered: "", expected: "Project" },
|
|
{ remembered: "deleted", expected: "Project" },
|
|
])("restores the last available project ($remembered)", async ({ remembered, expected }) => {
|
|
trackRecentProject(remembered, "company-1");
|
|
const { root } = renderDialog(container);
|
|
await waitForAssertion(() => expect(container.querySelector('[data-slot="new-issue-compact-control"]')?.textContent).toContain(expected));
|
|
await typeTextareaValue(container.querySelector<HTMLTextAreaElement>('textarea[aria-label="Describe a task…"]')!, "Check project memory");
|
|
act(() => container.querySelector<HTMLButtonElement>('[aria-label="Create task"]')!.click());
|
|
await waitForAssertion(() => expect(mockIssuesApi.create).toHaveBeenCalled());
|
|
expect(mockIssuesApi.create.mock.calls[0][1].projectId).toBe(remembered === "project-1" ? "project-1" : undefined);
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("keeps explicit launch context ahead of another company's project history", async () => {
|
|
trackRecentProject("project-1", "company-2");
|
|
trackRecentProject("deleted", "company-1");
|
|
dialogState.newIssueDefaults = { projectId: "project-1" };
|
|
const { root } = renderDialog(container);
|
|
await waitForAssertion(() => expect(container.querySelector('[data-slot="new-issue-compact-control"]')?.textContent).toContain("project-1"));
|
|
act(() => root.unmount());
|
|
dialogState.newIssueDefaults = {};
|
|
const next = renderDialog(container);
|
|
await flush();
|
|
expect(container.querySelector('[data-slot="new-issue-compact-control"]')?.textContent).toContain("Project");
|
|
act(() => next.root.unmount());
|
|
});
|
|
|
|
it("remembers a project selection even when the composer is dismissed without submitting", async () => {
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
act(() => container.querySelector<HTMLElement>('[data-slot="new-issue-compact-control"]')!.click());
|
|
expect(getLastProjectId("company-1")).toBe("project-1");
|
|
act(() => root.unmount());
|
|
const next = renderDialog(container);
|
|
await waitForAssertion(() => expect(container.querySelector('[data-slot="new-issue-compact-control"]')?.textContent).toContain("project-1"));
|
|
act(() => next.root.unmount());
|
|
});
|
|
|
|
it("shows No project first and lets a selected project be cleared", async () => {
|
|
dialogState.newIssueDefaults = { projectId: "project-1" };
|
|
const { root } = renderDialog(container);
|
|
await waitForAssertion(() => expect(container.querySelector('[data-slot="new-issue-compact-control"]')?.textContent).toContain("project-1"));
|
|
|
|
const projectPicker = container.querySelector<HTMLButtonElement>('[data-slot="new-issue-compact-control"]')!;
|
|
expect(projectPicker.dataset.noneAtTop).toBe("true");
|
|
act(() => projectPicker.click());
|
|
|
|
await typeTextareaValue(container.querySelector<HTMLTextAreaElement>('textarea[aria-label="Describe a task…"]')!, "Create without a project");
|
|
act(() => container.querySelector<HTMLButtonElement>('[aria-label="Create task"]')!.click());
|
|
await waitForAssertion(() => expect(mockIssuesApi.create).toHaveBeenCalled());
|
|
expect(mockIssuesApi.create.mock.calls[0][1].projectId).toBeUndefined();
|
|
expect(getLastProjectId("company-1")).toBe("");
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("submits remembered effort with the agent's default model", async () => {
|
|
mockAgentsApi.list.mockResolvedValue(defaultAgents.map((agent) => agent.id === "ceo"
|
|
? { ...agent, adapterConfig: { model: "claude-opus-5" } } : agent));
|
|
mockAgentsApi.adapterModels.mockResolvedValue([{ id: "claude-opus-5", label: "Claude Opus 5" }]);
|
|
rememberComposerEffort("company-1", "high");
|
|
const { root } = renderDialog(container);
|
|
await waitForAssertion(() => expect(container.querySelector('[data-testid="task-chat-composer-model-label"]')?.textContent).toBe("Claude Opus 5"));
|
|
await typeTextareaValue(container.querySelector<HTMLTextAreaElement>('textarea[aria-label="Describe a task…"]')!, "Check effort memory");
|
|
act(() => container.querySelector<HTMLButtonElement>('[aria-label="Create task"]')!.click());
|
|
await waitForAssertion(() => expect(mockIssuesApi.create).toHaveBeenCalled());
|
|
expect(mockIssuesApi.create.mock.calls[0][1].assigneeAdapterOverrides).toEqual({ adapterConfig: { effort: "high" } });
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it.each([
|
|
{ recent: "", expected: "CEO" },
|
|
{ recent: "agent:worker", expected: "Worker" },
|
|
{ recent: "user:user-1", expected: "Me" },
|
|
{ recent: "agent:deleted", expected: "CEO" },
|
|
])("defaults to $expected for recent selection $recent without inserting a mention", async ({ recent, expected }) => {
|
|
mockAgentsApi.list.mockResolvedValue(defaultAgents);
|
|
if (recent.startsWith("agent:")) trackRecentAssignee(recent.slice(6), "company-1");
|
|
if (recent.startsWith("user:")) trackRecentAssigneeUser(recent.slice(5), "company-1");
|
|
const { root } = renderDialog(container);
|
|
await waitForAssertion(() => expect(container.querySelector('[data-testid="task-chat-composer-assignee-label"]')?.textContent).toBe(expected));
|
|
expect(container.querySelector<HTMLTextAreaElement>('textarea[aria-label="Describe a task…"]')?.value).toBe("");
|
|
expect(container.textContent).not.toContain("@CEO");
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("ignores another company's remembered human and keeps explicit launch assignees", async () => {
|
|
mockAgentsApi.list.mockResolvedValue(defaultAgents);
|
|
trackRecentAssigneeUser("user-1", "company-2");
|
|
dialogState.newIssueDefaults = { assigneeAgentId: "worker" };
|
|
const { root } = renderDialog(container);
|
|
await waitForAssertion(() => expect(container.querySelector('[data-testid="task-chat-composer-assignee-label"]')?.textContent).toBe("Worker"));
|
|
act(() => root.unmount());
|
|
dialogState.newIssueDefaults = {};
|
|
const next = renderDialog(container);
|
|
await waitForAssertion(() => expect(container.querySelector('[data-testid="task-chat-composer-assignee-label"]')?.textContent).toBe("CEO"));
|
|
act(() => next.root.unmount());
|
|
});
|
|
|
|
it("uses older agent history only when the agent belongs to the current company", async () => {
|
|
mockAgentsApi.list.mockResolvedValue(defaultAgents);
|
|
trackRecentAssignee("worker");
|
|
trackRecentAssignee("another-company-agent");
|
|
const { root } = renderDialog(container);
|
|
await waitForAssertion(() => expect(container.querySelector('[data-testid="task-chat-composer-assignee-label"]')?.textContent).toBe("Worker"));
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("remembers a chosen human and leaves the request empty", async () => {
|
|
mockAgentsApi.list.mockResolvedValue(defaultAgents);
|
|
const { root } = renderDialog(container);
|
|
await waitForAssertion(() => expect(container.querySelector('[data-testid="task-chat-composer-assignee-label"]')?.textContent).toBe("CEO"));
|
|
act(() => container.querySelector<HTMLButtonElement>('[aria-label="Select assignee"]')!.click());
|
|
const me = [...container.querySelectorAll<HTMLButtonElement>('[role="option"]')].find((button) => button.textContent?.includes("Me"))!;
|
|
act(() => me.click());
|
|
expect(localStorage.getItem("paperclip:recent-assignees:company-1")).toContain("user:user-1");
|
|
expect(container.querySelector('[data-testid="task-chat-composer-model-label"]')).toBeNull();
|
|
expect(container.querySelector<HTMLTextAreaElement>('textarea[aria-label="Describe a task…"]')?.value).toBe("");
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("applies the default after agents load without replacing a request typed meanwhile", async () => {
|
|
let resolveAgents!: (agents: typeof defaultAgents) => void;
|
|
mockAgentsApi.list.mockReturnValue(new Promise((resolve) => { resolveAgents = resolve; }));
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
await typeTextareaValue(container.querySelector<HTMLTextAreaElement>('textarea[aria-label="Describe a task…"]')!, "hello there");
|
|
resolveAgents(defaultAgents);
|
|
await waitForAssertion(() => expect(container.querySelector('[data-testid="task-chat-composer-assignee-label"]')?.textContent).toBe("CEO"));
|
|
expect(container.querySelector<HTMLTextAreaElement>('textarea[aria-label="Describe a task…"]')?.value).toBe("hello there");
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("keeps an explicitly chosen human while agents are still loading", async () => {
|
|
let resolveAgents!: (agents: typeof defaultAgents) => void;
|
|
mockAgentsApi.list.mockReturnValue(new Promise((resolve) => { resolveAgents = resolve; }));
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
act(() => container.querySelector<HTMLButtonElement>('[aria-label="Select assignee"]')!.click());
|
|
const me = [...container.querySelectorAll<HTMLButtonElement>('[role="option"]')].find((button) => button.textContent?.includes("Me"))!;
|
|
act(() => me.click());
|
|
resolveAgents(defaultAgents);
|
|
await flush();
|
|
expect(container.querySelector('[data-testid="task-chat-composer-assignee-label"]')?.textContent).toBe("Me");
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("shows sub-issue context only when opened from a sub-issue action", async () => {
|
|
dialogState.newIssueDefaults = {
|
|
parentId: "issue-1",
|
|
parentIdentifier: "PAP-1",
|
|
parentTitle: "Parent issue",
|
|
projectId: "project-1",
|
|
goalId: "goal-1",
|
|
};
|
|
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
|
|
expect(container.textContent).toContain("New sub-task");
|
|
expect(container.textContent).toContain("Sub-task of");
|
|
expect(container.textContent).toContain("PAP-1");
|
|
expect(container.textContent).toContain("Parent issue");
|
|
expect(container.querySelector('[aria-label="Create sub-task"]')).not.toBeNull();
|
|
|
|
act(() => root.unmount());
|
|
|
|
dialogState.newIssueDefaults = {};
|
|
const rerendered = renderDialog(container);
|
|
await flush();
|
|
|
|
expect(container.textContent).toContain("New task");
|
|
expect(container.querySelector('[aria-label="Create task"]')).not.toBeNull();
|
|
expect(container.textContent).not.toContain("Sub-task of");
|
|
|
|
act(() => rerendered.root.unmount());
|
|
});
|
|
|
|
it("uses the task chat composer for the editor and send control", async () => {
|
|
mockAgentsApi.list.mockResolvedValue([{ id: "agent-1", name: "Coder", status: "active", adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: {} }]);
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
const composer = container.querySelector(".paperclip-task-chat-composer");
|
|
expect(composer).not.toBeNull();
|
|
expect(composer?.querySelector('[data-testid="task-chat-composer-input"]')).not.toBeNull();
|
|
expect(composer?.querySelector('[aria-label="Create task"]')).not.toBeNull();
|
|
expect(composer?.querySelector('[aria-label="Task settings"]')).toBeNull();
|
|
expect(container.querySelector("h2")?.className).toBe("sr-only");
|
|
expect(composer?.textContent).not.toContain("PAP");
|
|
await waitForAssertion(() => expect(composer?.querySelector('[data-testid="task-chat-composer-assignee"]')).not.toBeNull());
|
|
const toolbar = composer?.querySelector('[data-testid="task-chat-composer-actions"]');
|
|
const context = container.querySelector('[data-testid="task-chat-composer-context"]');
|
|
const project = context?.querySelector('[data-slot="new-issue-compact-control"]');
|
|
const assignee = toolbar?.querySelector('[data-testid="task-chat-composer-assignee"]');
|
|
expect(project).not.toBeNull();
|
|
expect(context?.nextElementSibling).toBe(composer);
|
|
expect(assignee).not.toBeNull();
|
|
expect(toolbar?.querySelector('[data-slot="new-issue-compact-control"]')).toBeNull();
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("submits parent and goal context for sub-issues", async () => {
|
|
mockProjectsApi.list.mockResolvedValue([
|
|
{
|
|
id: "project-1",
|
|
name: "Alpha",
|
|
description: null,
|
|
archivedAt: null,
|
|
color: "#445566",
|
|
executionWorkspacePolicy: {
|
|
enabled: true,
|
|
defaultMode: "shared_workspace",
|
|
},
|
|
},
|
|
]);
|
|
mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([
|
|
{
|
|
id: "workspace-1",
|
|
name: "Parent workspace",
|
|
mode: "isolated_workspace",
|
|
status: "active",
|
|
branchName: "feature/pap-1",
|
|
cwd: "/tmp/workspace-1",
|
|
projectWorkspaceId: null,
|
|
lastUsedAt: new Date("2026-04-06T16:00:00.000Z"),
|
|
},
|
|
]);
|
|
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
|
|
dialogState.newIssueDefaults = {
|
|
parentId: "issue-1",
|
|
parentIdentifier: "PAP-1",
|
|
parentTitle: "Parent issue",
|
|
title: "Child issue",
|
|
projectId: "project-1",
|
|
executionWorkspaceId: "workspace-1",
|
|
goalId: "goal-1",
|
|
};
|
|
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
|
|
await waitForAssertion(() => {
|
|
expect(mockExecutionWorkspacesApi.listSummaries).toHaveBeenCalledWith("company-1", {
|
|
projectId: "project-1",
|
|
reuseEligible: true,
|
|
});
|
|
});
|
|
expect(mockExecutionWorkspacesApi.list).not.toHaveBeenCalled();
|
|
|
|
const submitButton = Array.from(container.querySelectorAll("button"))
|
|
.find((button) => button.getAttribute("aria-label") === "Create sub-task");
|
|
expect(submitButton).not.toBeUndefined();
|
|
await waitForAssertion(() => {
|
|
expect(submitButton?.hasAttribute("disabled")).toBe(false);
|
|
});
|
|
|
|
await act(async () => {
|
|
submitButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flush();
|
|
|
|
expect(mockIssuesApi.create).toHaveBeenCalledWith(
|
|
"company-1",
|
|
expect.objectContaining({
|
|
title: "Child issue",
|
|
parentId: "issue-1",
|
|
goalId: "goal-1",
|
|
projectId: "project-1",
|
|
executionWorkspaceId: "workspace-1",
|
|
workMode: "standard",
|
|
}),
|
|
);
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("does not show user-secret warnings when the draft will not run an env binding that needs them", async () => {
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
|
|
expect(mockMissingUserSecretsBannerRender).not.toHaveBeenCalled();
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("scopes user-secret warnings to selected runnable agent and project env bindings", async () => {
|
|
dialogState.newIssueDefaults = {
|
|
title: "Run with scoped secrets",
|
|
assigneeAgentId: "agent-1",
|
|
projectId: "project-1",
|
|
};
|
|
mockAgentsApi.list.mockResolvedValue([
|
|
{
|
|
id: "agent-1",
|
|
name: "CodexCoder",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {
|
|
env: {
|
|
AGENT_TOKEN: { type: "user_secret_ref", key: "agent_token", required: true },
|
|
OPTIONAL_TOKEN: { type: "user_secret_ref", key: "optional_token", required: false },
|
|
},
|
|
},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
},
|
|
]);
|
|
mockProjectsApi.list.mockResolvedValue([
|
|
{
|
|
id: "project-1",
|
|
name: "Alpha",
|
|
description: null,
|
|
archivedAt: null,
|
|
color: "#445566",
|
|
env: {
|
|
PROJECT_TOKEN: { type: "user_secret_ref", key: "project_token", required: true },
|
|
},
|
|
},
|
|
]);
|
|
|
|
const { root } = renderDialog(container);
|
|
await waitForAssertion(() => {
|
|
expect(mockMissingUserSecretsBannerRender).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
definitionKeys: ["agent_token", "project_token"],
|
|
}),
|
|
);
|
|
});
|
|
|
|
expect(container.textContent).toContain("agent_token,project_token");
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("shows Astra-only efforts when a task inherits the agent model", async () => {
|
|
dialogState.newIssueDefaults = {
|
|
title: "Use inherited Astra",
|
|
assigneeAgentId: "agent-1",
|
|
};
|
|
mockAgentsApi.list.mockResolvedValue([
|
|
{
|
|
id: "agent-1",
|
|
name: "CodexCoder",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: { model: "gpt-6-astra" },
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
},
|
|
]);
|
|
|
|
const { root } = renderDialog(container);
|
|
await waitForAssertion(() => {
|
|
expect(container.querySelector('[aria-label="Select model and effort"]')).not.toBeNull();
|
|
expect(container.querySelector('[aria-label="Effort"]')?.getAttribute("max")).toBe("6");
|
|
});
|
|
expect(container.textContent).not.toContain("Codex options");
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("warns when the selected assignee is a paused imported agent", async () => {
|
|
dialogState.newIssueDefaults = {
|
|
title: "Compare onboarding flows",
|
|
assigneeAgentId: "agent-1",
|
|
};
|
|
mockAgentsApi.list.mockResolvedValue([
|
|
{
|
|
id: "agent-1",
|
|
name: "CEO",
|
|
status: "paused",
|
|
pauseReason: "import",
|
|
adapterType: "claude_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
},
|
|
]);
|
|
|
|
const { root } = renderDialog(container);
|
|
await waitForAssertion(() => {
|
|
expect(container.querySelector('[data-testid="new-issue-paused-assignee-note"]')).not.toBeNull();
|
|
});
|
|
expect(container.textContent).toContain("arrived paused from an organization import");
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("restores the planning mode from dialog defaults", async () => {
|
|
dialogState.newIssueDefaults = {
|
|
title: "Planned from defaults",
|
|
workMode: "planning",
|
|
};
|
|
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
|
|
const planningButton = container.querySelector('[data-testid="composer-add-plan"]');
|
|
expect(container.querySelector("[data-pending-work-mode=planning]")).not.toBeNull();
|
|
|
|
const submitButton = Array.from(container.querySelectorAll("button"))
|
|
.find((button) => button.getAttribute("aria-label") === "Create task");
|
|
expect(submitButton).not.toBeUndefined();
|
|
await vi.waitFor(() => {
|
|
expect(submitButton?.hasAttribute("disabled")).toBe(false);
|
|
});
|
|
|
|
await act(async () => {
|
|
submitButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flush();
|
|
|
|
expect(mockIssuesApi.create).toHaveBeenCalledWith(
|
|
"company-1",
|
|
expect.objectContaining({
|
|
title: "Planned from defaults",
|
|
workMode: "planning",
|
|
}),
|
|
);
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("restores ask mode from dialog defaults", async () => {
|
|
dialogState.newIssueDefaults = {
|
|
title: "Question from defaults",
|
|
workMode: "ask",
|
|
};
|
|
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
|
|
const askButton = container.querySelector('[data-testid="composer-add-ask"]');
|
|
expect(container.querySelector("[data-pending-work-mode=ask]")).not.toBeNull();
|
|
|
|
const submitButton = Array.from(container.querySelectorAll("button"))
|
|
.find((button) => button.getAttribute("aria-label") === "Create task");
|
|
expect(submitButton).not.toBeUndefined();
|
|
await vi.waitFor(() => {
|
|
expect(submitButton?.hasAttribute("disabled")).toBe(false);
|
|
});
|
|
|
|
await act(async () => {
|
|
submitButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flush();
|
|
|
|
expect(mockIssuesApi.create).toHaveBeenCalledWith(
|
|
"company-1",
|
|
expect.objectContaining({
|
|
title: "Question from defaults",
|
|
workMode: "ask",
|
|
}),
|
|
);
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("hides isolation choices and omits stale workspace draft overrides", async () => {
|
|
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
|
|
mockProjectsApi.list.mockResolvedValue([{
|
|
id: "project-1", name: "Alpha", workspaces: [],
|
|
executionWorkspacePolicy: { enabled: true, defaultMode: "isolated_workspace" },
|
|
}]);
|
|
localStorage.setItem("paperclip:issue-draft", JSON.stringify({
|
|
title: "Draft task", description: "", status: "todo", priority: "medium", assigneeValue: "",
|
|
reviewerValue: "", approverValue: "", projectId: "project-1",
|
|
selectedExecutionWorkspaceId: "stale-workspace", executionWorkspaceMode: "reuse_existing",
|
|
assigneeModelOverride: "", assigneeThinkingEffort: "", assigneeChrome: false, workMode: "standard",
|
|
}));
|
|
const { root } = renderDialog(container, ["workspaces.isolation"]);
|
|
await flush();
|
|
expect(container.textContent).not.toContain("Execution workspace");
|
|
expect(container.querySelector('option[value="isolated_workspace"]')).toBeNull();
|
|
await typeTextareaValue(container.querySelector('textarea[aria-label="Describe a task…"]')!, "Managed task");
|
|
const create = Array.from(container.querySelectorAll("button")).find((button) => button.getAttribute("aria-label") === "Create task");
|
|
act(() => create!.click());
|
|
await waitForAssertion(() => expect(mockIssuesApi.create).toHaveBeenCalled());
|
|
const payload = mockIssuesApi.create.mock.calls[0][1];
|
|
expect(payload).not.toHaveProperty("executionWorkspacePreference");
|
|
expect(payload).not.toHaveProperty("executionWorkspaceSettings");
|
|
expect(payload).not.toHaveProperty("executionWorkspaceId");
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it.each([false, true])("keeps explicit workspace launch context when isolation controls are hidden (subtask: %s)", async (subtask) => {
|
|
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
|
|
dialogState.newIssueDefaults = {
|
|
projectId: "project-1", executionWorkspaceId: "workspace-context",
|
|
...(subtask ? { parentId: "parent-task", parentIdentifier: "TEST-1" } : {}),
|
|
};
|
|
const { root } = renderDialog(container, ["workspaces.isolation"]);
|
|
await flush();
|
|
expect(container.querySelector('option[value="isolated_workspace"]')).toBeNull();
|
|
await typeTextareaValue(container.querySelector('textarea[aria-label="Describe a task…"]')!, "Context task");
|
|
const create = Array.from(container.querySelectorAll("button")).find((button) => button.getAttribute("aria-label") === (subtask ? "Create sub-task" : "Create task"));
|
|
act(() => create!.click());
|
|
await waitForAssertion(() => expect(mockIssuesApi.create).toHaveBeenCalled());
|
|
expect(mockIssuesApi.create.mock.calls[0][1]).toMatchObject({
|
|
executionWorkspaceId: "workspace-context", executionWorkspacePreference: "reuse_existing",
|
|
});
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("applies project and execution workspace defaults for normal new issues", async () => {
|
|
mockProjectsApi.list.mockResolvedValue([
|
|
{
|
|
id: "project-1",
|
|
name: "Alpha",
|
|
description: null,
|
|
archivedAt: null,
|
|
color: "#445566",
|
|
workspaces: [
|
|
{
|
|
id: "project-workspace-1",
|
|
name: "Primary",
|
|
isPrimary: true,
|
|
},
|
|
{
|
|
id: "project-workspace-2",
|
|
name: "Isolated checkout",
|
|
isPrimary: false,
|
|
},
|
|
],
|
|
executionWorkspacePolicy: {
|
|
enabled: true,
|
|
defaultMode: "shared_workspace",
|
|
},
|
|
},
|
|
]);
|
|
mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([
|
|
{
|
|
id: "workspace-1",
|
|
name: "PAP-100",
|
|
mode: "isolated_workspace",
|
|
status: "active",
|
|
branchName: "feature/pap-100",
|
|
cwd: "/tmp/workspace-1",
|
|
projectWorkspaceId: "project-workspace-2",
|
|
lastUsedAt: new Date("2026-04-06T16:00:00.000Z"),
|
|
},
|
|
]);
|
|
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
|
|
dialogState.newIssueDefaults = {
|
|
title: "Follow-up issue",
|
|
projectId: "project-1",
|
|
projectWorkspaceId: "project-workspace-2",
|
|
executionWorkspaceId: "workspace-1",
|
|
};
|
|
|
|
const { root, queryClient } = renderDialog(container);
|
|
await flush();
|
|
|
|
await waitForAssertion(() => {
|
|
expect(queryClient.getQueryData(queryKeys.executionWorkspaces.summaryList("company-1", {
|
|
projectId: "project-1", reuseEligible: true,
|
|
}))).toEqual(expect.arrayContaining([expect.objectContaining({ id: "workspace-1" })]));
|
|
});
|
|
await flush();
|
|
expect(container.textContent).toContain("New task");
|
|
expect(container.textContent).not.toContain("New sub-task");
|
|
|
|
const submitButton = Array.from(container.querySelectorAll("button"))
|
|
.find((button) => button.getAttribute("aria-label") === "Create task");
|
|
expect(submitButton).not.toBeUndefined();
|
|
|
|
await act(async () => {
|
|
submitButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flush();
|
|
|
|
expect(mockIssuesApi.create).toHaveBeenCalledWith(
|
|
"company-1",
|
|
expect.objectContaining({
|
|
title: "Follow-up issue",
|
|
projectId: "project-1",
|
|
projectWorkspaceId: "project-workspace-2",
|
|
executionWorkspaceId: "workspace-1",
|
|
executionWorkspacePreference: "reuse_existing",
|
|
executionWorkspaceSettings: {
|
|
mode: "isolated_workspace",
|
|
},
|
|
}),
|
|
);
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
function enableWorktrees(mode = "shared_workspace") {
|
|
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
|
|
mockProjectsApi.list.mockResolvedValue([
|
|
{ id: "project-1", name: "Alpha", executionWorkspacePolicy: { enabled: true, defaultMode: mode }, workspaces: [{ id: "source-1", isPrimary: true }] },
|
|
{ id: "project-2", name: "Beta", executionWorkspacePolicy: { enabled: false }, workspaces: [] },
|
|
]);
|
|
dialogState.newIssueDefaults = { projectId: "project-1", description: "Fix sign-in" };
|
|
}
|
|
|
|
function selectWorktree(value: string) {
|
|
const option = container.querySelector<HTMLElement>(`[cmdk-item][data-value="${value}"]`);
|
|
expect(option).not.toBeNull();
|
|
act(() => option!.click());
|
|
}
|
|
|
|
it.each(["isolated_workspace", "operator_branch", "adapter_managed"])("reuses a worktree with its actual mode (%s)", async (mode) => {
|
|
enableWorktrees();
|
|
mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([{
|
|
id: "worktree-1", name: "Sign-in fix", mode, status: "active", branchName: "fix/sign-in",
|
|
cwd: "/tmp/worktree-1", projectWorkspaceId: "source-1", lastUsedAt: new Date(),
|
|
}]);
|
|
const { root } = renderDialog(container);
|
|
await waitForAssertion(() => expect(container.querySelector('[cmdk-item][data-value="reuse:worktree-1"]')).not.toBeNull());
|
|
const context = container.querySelector('[data-testid="task-chat-composer-context"]');
|
|
const project = context?.querySelector('[data-slot="new-issue-compact-control"]');
|
|
const worktrees = context?.querySelector('[aria-label="Worktrees"]');
|
|
expect(worktrees).not.toBeNull();
|
|
expect(project?.compareDocumentPosition(worktrees!)! & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
selectWorktree("reuse:worktree-1");
|
|
act(() => container.querySelector<HTMLButtonElement>('[aria-label="Create task"]')!.click());
|
|
await waitForAssertion(() => expect(mockIssuesApi.create).toHaveBeenCalled());
|
|
expect(mockIssuesApi.create.mock.calls[0][1]).toMatchObject({
|
|
projectId: "project-1", projectWorkspaceId: "source-1", executionWorkspaceId: "worktree-1",
|
|
executionWorkspacePreference: "reuse_existing",
|
|
executionWorkspaceSettings: { mode: mode === "adapter_managed" ? "agent_default" : mode },
|
|
});
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it.each(["isolated_workspace", "shared_workspace"])("restores the project checkout and clears reuse when choosing %s", async (mode) => {
|
|
enableWorktrees();
|
|
mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([{
|
|
id: "worktree-1", name: "Sign-in fix", mode: "isolated_workspace", status: "idle",
|
|
branchName: "fix/sign-in", cwd: "/tmp/worktree-1", projectWorkspaceId: "source-2", lastUsedAt: new Date(),
|
|
}]);
|
|
const { root } = renderDialog(container);
|
|
await waitForAssertion(() => expect(container.querySelector('[cmdk-item][data-value="reuse:worktree-1"]')).not.toBeNull());
|
|
selectWorktree("reuse:worktree-1");
|
|
selectWorktree(mode);
|
|
act(() => container.querySelector<HTMLButtonElement>('[aria-label="Create task"]')!.click());
|
|
await waitForAssertion(() => expect(mockIssuesApi.create).toHaveBeenCalled());
|
|
const payload = mockIssuesApi.create.mock.calls[0][1];
|
|
expect(payload).toMatchObject({ projectWorkspaceId: "source-1", executionWorkspacePreference: mode, executionWorkspaceSettings: { mode } });
|
|
expect(payload).not.toHaveProperty("executionWorkspaceId");
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it.each([false, true])("carries the source checkout when reusing another worktree in the same project (inherited: %s)", async (inherited) => {
|
|
enableWorktrees();
|
|
if (inherited) dialogState.newIssueDefaults.executionWorkspaceId = "worktree-secondary";
|
|
mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([{
|
|
id: "worktree-secondary", name: "Secondary checkout fix", mode: "isolated_workspace", status: "active",
|
|
branchName: "fix/secondary", cwd: "/tmp/worktree-secondary", projectWorkspaceId: "source-2", lastUsedAt: new Date(),
|
|
}]);
|
|
const { root } = renderDialog(container);
|
|
await waitForAssertion(() => expect(container.querySelector('[cmdk-item][data-value="reuse:worktree-secondary"]')).not.toBeNull());
|
|
expect(mockExecutionWorkspacesApi.listSummaries).toHaveBeenCalledWith("company-1", { projectId: "project-1", reuseEligible: true });
|
|
if (!inherited) selectWorktree("reuse:worktree-secondary");
|
|
act(() => container.querySelector<HTMLButtonElement>('[aria-label="Create task"]')!.click());
|
|
await waitForAssertion(() => expect(mockIssuesApi.create).toHaveBeenCalled());
|
|
expect(mockIssuesApi.create.mock.calls[0][1]).toMatchObject({ projectWorkspaceId: "source-2", executionWorkspaceId: "worktree-secondary" });
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("clears a reused worktree when switching to a project without isolation", async () => {
|
|
enableWorktrees();
|
|
dialogState.newIssueDefaults.executionWorkspaceId = "worktree-1";
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
act(() => container.querySelector<HTMLButtonElement>('[data-slot="new-issue-compact-control"]')!.click());
|
|
await flush();
|
|
expect(container.querySelector('[aria-label="Worktrees"]')).toBeNull();
|
|
act(() => container.querySelector<HTMLButtonElement>('[aria-label="Create task"]')!.click());
|
|
await waitForAssertion(() => expect(mockIssuesApi.create).toHaveBeenCalled());
|
|
const payload = mockIssuesApi.create.mock.calls[0][1];
|
|
expect(payload.projectId).toBe("project-2");
|
|
expect(payload).not.toHaveProperty("executionWorkspaceId");
|
|
expect(payload).not.toHaveProperty("executionWorkspacePreference");
|
|
expect(payload).not.toHaveProperty("executionWorkspaceSettings");
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it.each([false, true])("hides worktrees and skips reuse queries when the project has isolation disabled (instance: %s)", async (instanceEnabled) => {
|
|
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: instanceEnabled });
|
|
dialogState.newIssueDefaults = { projectId: "project-1" };
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
expect(container.querySelector('[aria-label="Worktrees"]')).toBeNull();
|
|
expect(mockExecutionWorkspacesApi.listSummaries).not.toHaveBeenCalled();
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it.each(["loading", "error", "missing"])("blocks an unchecked reused worktree but still permits a new one (%s)", async (state) => {
|
|
enableWorktrees();
|
|
dialogState.newIssueDefaults.executionWorkspaceId = "stale-worktree";
|
|
if (state === "loading") mockExecutionWorkspacesApi.listSummaries.mockImplementation(() => new Promise(() => {}));
|
|
if (state === "error") mockExecutionWorkspacesApi.listSummaries.mockRejectedValue(new Error("Unavailable"));
|
|
const { root } = renderDialog(container);
|
|
await waitForAssertion(() => expect(container.querySelector('[aria-label="Worktrees"]')).not.toBeNull());
|
|
await flush();
|
|
const submit = container.querySelector<HTMLButtonElement>('[aria-label="Create task"]')!;
|
|
expect(submit.disabled).toBe(true);
|
|
act(() => { container.querySelector('textarea')!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", metaKey: true, bubbles: true })); });
|
|
expect(mockIssuesApi.create).not.toHaveBeenCalled();
|
|
selectWorktree("isolated_workspace");
|
|
await flush();
|
|
expect(submit.disabled).toBe(false);
|
|
act(() => submit.click());
|
|
await waitForAssertion(() => expect(mockIssuesApi.create).toHaveBeenCalled());
|
|
expect(mockIssuesApi.create.mock.calls[0][1]).toMatchObject({ executionWorkspacePreference: "isolated_workspace" });
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
|
|
it("restores a description-only draft", async () => {
|
|
localStorage.setItem("paperclip:issue-draft", JSON.stringify({
|
|
title: "", description: "Keep the request without a title", status: "todo", priority: "medium",
|
|
assigneeValue: "", reviewerValue: "", approverValue: "", projectId: "",
|
|
assigneeModelOverride: "", assigneeThinkingEffort: "", assigneeChrome: false,
|
|
}));
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
await waitForAssertion(() => {
|
|
expect((container.querySelector('textarea[aria-label="Describe a task…"]') as HTMLTextAreaElement).value).toBe("Keep the request without a title");
|
|
});
|
|
const submit = Array.from(container.querySelectorAll("button")).find(button => button.getAttribute("aria-label") === "Create task")!;
|
|
expect(submit.hasAttribute("disabled")).toBe(false);
|
|
await act(async () => root.unmount());
|
|
});
|
|
|
|
it("creates a task from its description without requiring a title", async () => {
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
const submit = Array.from(container.querySelectorAll("button")).find(button => button.getAttribute("aria-label") === "Create task")!;
|
|
expect(submit.hasAttribute("disabled")).toBe(true);
|
|
await typeTextareaValue(container.querySelector('textarea[aria-label="Describe a task…"]')!, "Investigate the sign-in redirect and fix it");
|
|
await vi.waitFor(() => expect(submit.hasAttribute("disabled")).toBe(false));
|
|
await act(async () => { submit.dispatchEvent(new MouseEvent("click", { bubbles: true })); });
|
|
await flush();
|
|
expect(mockIssuesApi.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ description: "Investigate the sign-in redirect and fix it" }));
|
|
expect(mockIssuesApi.create.mock.calls[0][1]).not.toHaveProperty("title");
|
|
await act(async () => root.unmount());
|
|
});
|
|
|
|
it.each(["Corrected task title", ""])("lets users edit or clear an inherited title (%s)", async (nextTitle) => {
|
|
dialogState.newIssueDefaults = { title: "Suggested title", description: "Investigate the redirect" };
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
const titleInput = container.querySelector<HTMLInputElement>('input[aria-label="Task title"]')!;
|
|
expect(titleInput.value).toBe("Suggested title");
|
|
await typeTextareaValue(titleInput, nextTitle);
|
|
expect(container.querySelector('input[aria-label="Task title"]')).toBe(titleInput);
|
|
const submit = container.querySelector<HTMLButtonElement>('[aria-label="Create task"]')!;
|
|
await act(async () => submit.click());
|
|
await flush();
|
|
const payload = mockIssuesApi.create.mock.calls[0][1];
|
|
expect(payload.description).toBe("Investigate the redirect");
|
|
if (nextTitle) expect(payload.title).toBe(nextTitle);
|
|
else expect(payload).not.toHaveProperty("title");
|
|
await act(async () => root.unmount());
|
|
});
|
|
|
|
it("keeps a typed request when project data arrives after editing starts", async () => {
|
|
let resolveProjects: (projects: Array<{
|
|
id: string;
|
|
name: string;
|
|
description: string | null;
|
|
archivedAt: string | null;
|
|
color: string;
|
|
}>) => void = () => undefined;
|
|
mockProjectsApi.list.mockReturnValue(new Promise((resolve) => {
|
|
resolveProjects = resolve;
|
|
}));
|
|
|
|
dialogState.newIssueDefaults = { title: "Typed issue" };
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
|
|
const descriptionInput = container.querySelector('textarea[aria-label="Describe a task…"]') as HTMLTextAreaElement | null;
|
|
expect(descriptionInput).not.toBeNull();
|
|
|
|
await typeTextareaValue(descriptionInput!, "Typed description");
|
|
|
|
await act(async () => {
|
|
resolveProjects([
|
|
{
|
|
id: "project-1",
|
|
name: "Alpha",
|
|
description: null,
|
|
archivedAt: null,
|
|
color: "#445566",
|
|
},
|
|
]);
|
|
await Promise.resolve();
|
|
});
|
|
await flush();
|
|
|
|
const submitButton = Array.from(container.querySelectorAll("button"))
|
|
.find((button) => button.getAttribute("aria-label") === "Create task");
|
|
expect(submitButton).not.toBeUndefined();
|
|
await vi.waitFor(() => {
|
|
expect(submitButton?.hasAttribute("disabled")).toBe(false);
|
|
});
|
|
|
|
await act(async () => {
|
|
submitButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flush();
|
|
|
|
expect(mockIssuesApi.create).toHaveBeenCalledWith(
|
|
"company-1",
|
|
expect.objectContaining({
|
|
title: "Typed issue",
|
|
description: "Typed description",
|
|
workMode: "standard",
|
|
}),
|
|
);
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("shows the create-task loading state only in the submit button", async () => {
|
|
mockIssuesApi.create.mockReturnValue(new Promise(() => undefined));
|
|
dialogState.newIssueDefaults = { title: "Pending task" };
|
|
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
|
|
const submitButton = Array.from(container.querySelectorAll("button"))
|
|
.find((button) => button.getAttribute("aria-label") === "Create task");
|
|
expect(submitButton).not.toBeUndefined();
|
|
|
|
await act(async () => {
|
|
submitButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flush();
|
|
|
|
expect(submitButton?.querySelector(".animate-spin")).not.toBeNull();
|
|
expect(submitButton?.getAttribute("aria-busy")).toBe("true");
|
|
expect(container.textContent).not.toContain("Creating issue");
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("creates separate tasks with the same title and confirms each with a link", async () => {
|
|
dialogState.newIssueDefaults = { title: "Connect Circleback through Composio" };
|
|
const { root, queryClient } = renderDialog(container);
|
|
await flush();
|
|
const submit = () => Array.from(container.querySelectorAll("button"))
|
|
.find((button) => button.getAttribute("aria-label") === "Create task")!;
|
|
|
|
act(() => submit().click());
|
|
await waitForAssertion(() => expect(dialogState.closeNewIssue).toHaveBeenCalledTimes(1));
|
|
const firstPayload = mockIssuesApi.create.mock.calls[0][1];
|
|
expect(firstPayload).toMatchObject({
|
|
title: "Connect Circleback through Composio",
|
|
allowDuplicate: true,
|
|
idempotencyKey: expect.any(String),
|
|
});
|
|
expect(toastState.pushToast).toHaveBeenCalledWith({
|
|
title: "Created PAP-2",
|
|
tone: "success",
|
|
action: { label: "Open PAP-2", href: "/PAP/issues/PAP-2" },
|
|
});
|
|
|
|
dialogState.newIssueOpen = false;
|
|
act(() => root.render(<QueryClientProvider client={queryClient}><NewIssueDialog /></QueryClientProvider>));
|
|
dialogState.newIssueOpen = true;
|
|
act(() => root.render(<QueryClientProvider client={queryClient}><NewIssueDialog /></QueryClientProvider>));
|
|
await flush();
|
|
act(() => submit().click());
|
|
await waitForAssertion(() => expect(dialogState.closeNewIssue).toHaveBeenCalledTimes(2));
|
|
const secondPayload = mockIssuesApi.create.mock.calls[1][1];
|
|
expect(secondPayload.title).toBe(firstPayload.title);
|
|
expect(secondPayload.allowDuplicate).toBe(true);
|
|
expect(secondPayload.idempotencyKey).not.toBe(firstPayload.idempotencyKey);
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("creates a task on plain HTTP when randomUUID is unavailable", async () => {
|
|
const originalCrypto = globalThis.crypto;
|
|
vi.stubGlobal("crypto", { getRandomValues: originalCrypto.getRandomValues.bind(originalCrypto) });
|
|
try {
|
|
dialogState.newIssueDefaults = { title: "LAN preview task" };
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
act(() => Array.from(container.querySelectorAll("button"))
|
|
.find(button => button.getAttribute("aria-label") === "Create task")!.click());
|
|
await waitForAssertion(() => expect(dialogState.closeNewIssue).toHaveBeenCalledTimes(1));
|
|
expect(mockIssuesApi.create.mock.calls[0][1].idempotencyKey)
|
|
.toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
|
|
act(() => root.unmount());
|
|
} finally {
|
|
vi.unstubAllGlobals();
|
|
}
|
|
});
|
|
|
|
it.each([undefined, false, true])("navigates after creation only when navigateOnCreate is true (%s)", async (navigateOnCreate) => {
|
|
dialogState.newIssueDefaults = { title: "Connect Circleback through Composio", navigateOnCreate };
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
const submit = Array.from(container.querySelectorAll("button"))
|
|
.find((button) => button.getAttribute("aria-label") === "Create task")!;
|
|
act(() => submit.click());
|
|
await waitForAssertion(() => expect(dialogState.closeNewIssue).toHaveBeenCalledTimes(1));
|
|
expect(mockIssuesApi.create.mock.calls[0][1]).not.toHaveProperty("navigateOnCreate");
|
|
if (navigateOnCreate) {
|
|
expect(navigateMock).toHaveBeenCalledExactlyOnceWith("/PAP/issues/PAP-2");
|
|
expect(navigateMock.mock.invocationCallOrder[0]).toBeGreaterThan(dialogState.closeNewIssue.mock.invocationCallOrder[0]);
|
|
} else {
|
|
expect(navigateMock).not.toHaveBeenCalled();
|
|
}
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("navigates to the returned task ID when it has no identifier", async () => {
|
|
dialogState.newIssueDefaults = { title: "Connect Circleback through Composio", navigateOnCreate: true };
|
|
mockIssuesApi.create.mockResolvedValue({ id: "created-task", companyId: "company-1", identifier: null });
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
act(() => Array.from(container.querySelectorAll("button"))
|
|
.find((button) => button.getAttribute("aria-label") === "Create task")!.click());
|
|
await waitForAssertion(() => expect(navigateMock).toHaveBeenCalledExactlyOnceWith("/PAP/issues/created-task"));
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("reuses a create request key after a failure until the submitted draft changes", async () => {
|
|
dialogState.newIssueDefaults = { title: "Connect Circleback through Composio", navigateOnCreate: true };
|
|
mockIssuesApi.create.mockRejectedValue(new Error("Request failed"));
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
const submit = () => Array.from(container.querySelectorAll("button"))
|
|
.find((button) => button.getAttribute("aria-label") === "Create task")!;
|
|
|
|
act(() => submit().click());
|
|
await waitForAssertion(() => expect(container.textContent).toContain("Request failed"));
|
|
const firstPayload = mockIssuesApi.create.mock.calls[0][1];
|
|
expect(dialogState.closeNewIssue).not.toHaveBeenCalled();
|
|
expect(toastState.pushToast).not.toHaveBeenCalled();
|
|
expect(navigateMock).not.toHaveBeenCalled();
|
|
|
|
act(() => submit().click());
|
|
await waitForAssertion(() => expect(mockIssuesApi.create).toHaveBeenCalledTimes(2));
|
|
await flush();
|
|
expect(mockIssuesApi.create.mock.calls[1][1].idempotencyKey).toBe(firstPayload.idempotencyKey);
|
|
|
|
await typeTextareaValue(container.querySelector<HTMLInputElement>('input[aria-label="Task title"]')!, "Connect another app");
|
|
act(() => submit().click());
|
|
await waitForAssertion(() => expect(mockIssuesApi.create).toHaveBeenCalledTimes(3));
|
|
expect(mockIssuesApi.create.mock.calls[2][1].idempotencyKey).not.toBe(firstPayload.idempotencyKey);
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("submits Chinese, Japanese, and Hindi issue text without normalization", async () => {
|
|
const title = "验证中文任务";
|
|
const description = [
|
|
"请用中文回复。",
|
|
"日本語: 次の手順を書いてください。",
|
|
"हिन्दी: कृपया स्थिति बताएं।",
|
|
].join("\n");
|
|
|
|
dialogState.newIssueDefaults = { title };
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
|
|
const descriptionInput = container.querySelector('textarea[aria-label="Describe a task…"]') as HTMLTextAreaElement | null;
|
|
expect(descriptionInput).not.toBeNull();
|
|
|
|
await typeTextareaValue(descriptionInput!, description);
|
|
|
|
const submitButton = Array.from(container.querySelectorAll("button"))
|
|
.find((button) => button.getAttribute("aria-label") === "Create task");
|
|
expect(submitButton).not.toBeUndefined();
|
|
await vi.waitFor(() => {
|
|
expect(submitButton?.hasAttribute("disabled")).toBe(false);
|
|
});
|
|
|
|
await act(async () => {
|
|
submitButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flush();
|
|
|
|
expect(mockIssuesApi.create).toHaveBeenCalledWith(
|
|
"company-1",
|
|
expect.objectContaining({
|
|
title,
|
|
description,
|
|
workMode: "standard",
|
|
}),
|
|
);
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("submits planning work mode when planning is selected", async () => {
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
|
|
const titleInput = container.querySelector('textarea[aria-label="Describe a task…"]') as HTMLTextAreaElement | null;
|
|
expect(titleInput).not.toBeNull();
|
|
await typeTextareaValue(titleInput!, "Plan this first");
|
|
|
|
const planningButton = container.querySelector('[data-testid="composer-add-plan"]');
|
|
expect(planningButton).not.toBeNull();
|
|
await act(async () => {
|
|
planningButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flush();
|
|
|
|
const submitButton = Array.from(container.querySelectorAll("button"))
|
|
.find((button) => button.getAttribute("aria-label") === "Create task");
|
|
expect(submitButton).not.toBeUndefined();
|
|
await vi.waitFor(() => {
|
|
expect(submitButton?.hasAttribute("disabled")).toBe(false);
|
|
});
|
|
|
|
await act(async () => {
|
|
submitButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flush();
|
|
|
|
expect(mockIssuesApi.create).toHaveBeenCalledWith(
|
|
"company-1",
|
|
expect.objectContaining({
|
|
description: "Plan this first",
|
|
workMode: "planning",
|
|
}),
|
|
);
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("submits ask work mode when ask is selected", async () => {
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
|
|
const titleInput = container.querySelector('textarea[aria-label="Describe a task…"]') as HTMLTextAreaElement | null;
|
|
expect(titleInput).not.toBeNull();
|
|
await typeTextareaValue(titleInput!, "Answer this first");
|
|
|
|
const askButton = container.querySelector('[data-testid="composer-add-ask"]');
|
|
expect(askButton).not.toBeNull();
|
|
await act(async () => {
|
|
askButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flush();
|
|
|
|
const submitButton = Array.from(container.querySelectorAll("button"))
|
|
.find((button) => button.getAttribute("aria-label") === "Create task");
|
|
expect(submitButton).not.toBeUndefined();
|
|
await vi.waitFor(() => {
|
|
expect(submitButton?.hasAttribute("disabled")).toBe(false);
|
|
});
|
|
|
|
await act(async () => {
|
|
submitButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flush();
|
|
|
|
expect(mockIssuesApi.create).toHaveBeenCalledWith(
|
|
"company-1",
|
|
expect.objectContaining({
|
|
description: "Answer this first",
|
|
workMode: "ask",
|
|
}),
|
|
);
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("cycles work modes with cmd-period", async () => {
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
|
|
const modeChip = () => container.querySelector("[data-testid=task-chat-composer-mode]");
|
|
expect(modeChip()).toBeNull();
|
|
|
|
await act(async () => {
|
|
container.querySelector(".paperclip-task-chat-composer")?.dispatchEvent(new KeyboardEvent("keydown", {
|
|
bubbles: true,
|
|
code: "",
|
|
key: ".",
|
|
metaKey: true,
|
|
}));
|
|
});
|
|
expect(modeChip()?.getAttribute("data-pending-work-mode")).toBe("planning");
|
|
expect(modeChip()?.textContent).toContain("Plan mode");
|
|
|
|
await act(async () => {
|
|
container.querySelector(".paperclip-task-chat-composer")?.dispatchEvent(new KeyboardEvent("keydown", {
|
|
bubbles: true,
|
|
code: "Period",
|
|
key: ".",
|
|
metaKey: true,
|
|
}));
|
|
});
|
|
expect(modeChip()?.getAttribute("data-pending-work-mode")).toBe("ask");
|
|
expect(modeChip()?.textContent).toContain("Ask mode");
|
|
|
|
await act(async () => {
|
|
container.querySelector(".paperclip-task-chat-composer")?.dispatchEvent(new KeyboardEvent("keydown", {
|
|
bubbles: true,
|
|
code: "Period",
|
|
key: ".",
|
|
metaKey: true,
|
|
}));
|
|
});
|
|
expect(modeChip()).toBeNull();
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("cycles work modes when iOS reports cmd-period as Escape", async () => {
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
|
|
const modeChip = () => container.querySelector("[data-testid=task-chat-composer-mode]");
|
|
expect(modeChip()).toBeNull();
|
|
expect(dialogContentState.onEscapeKeyDown).not.toBeNull();
|
|
|
|
const commandPeriodAsEscape = new KeyboardEvent("keydown", {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
key: "Escape",
|
|
metaKey: true,
|
|
});
|
|
await act(async () => {
|
|
dialogContentState.onEscapeKeyDown?.(commandPeriodAsEscape);
|
|
});
|
|
|
|
expect(commandPeriodAsEscape.defaultPrevented).toBe(true);
|
|
expect(modeChip()?.getAttribute("data-pending-work-mode")).toBe("planning");
|
|
expect(dialogState.closeNewIssue).not.toHaveBeenCalled();
|
|
|
|
const plainEscape = new KeyboardEvent("keydown", {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
key: "Escape",
|
|
});
|
|
await act(async () => {
|
|
dialogContentState.onEscapeKeyDown?.(plainEscape);
|
|
});
|
|
|
|
expect(plainEscape.defaultPrevented).toBe(false);
|
|
expect(modeChip()?.getAttribute("data-pending-work-mode")).toBe("planning");
|
|
|
|
const controlEscape = new KeyboardEvent("keydown", {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
ctrlKey: true,
|
|
key: "Escape",
|
|
});
|
|
await act(async () => {
|
|
dialogContentState.onEscapeKeyDown?.(controlEscape);
|
|
});
|
|
|
|
expect(controlEscape.defaultPrevented).toBe(false);
|
|
expect(modeChip()?.getAttribute("data-pending-work-mode")).toBe("planning");
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("cycles work modes with ctrl-period", async () => {
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
|
|
const modeChip = () => container.querySelector("[data-testid=task-chat-composer-mode]");
|
|
expect(modeChip()).toBeNull();
|
|
|
|
await act(async () => {
|
|
container.querySelector(".paperclip-task-chat-composer")?.dispatchEvent(new KeyboardEvent("keydown", {
|
|
bubbles: true,
|
|
code: "Period",
|
|
key: ".",
|
|
ctrlKey: true,
|
|
}));
|
|
});
|
|
expect(modeChip()?.getAttribute("data-pending-work-mode")).toBe("planning");
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("submits the parent assignee when a sub-issue opens with inherited defaults", async () => {
|
|
dialogState.newIssueDefaults = {
|
|
parentId: "issue-1",
|
|
parentIdentifier: "PAP-1",
|
|
parentTitle: "Parent issue",
|
|
title: "Child issue",
|
|
projectId: "project-1",
|
|
goalId: "goal-1",
|
|
assigneeAgentId: "agent-1",
|
|
};
|
|
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
|
|
const submitButton = Array.from(container.querySelectorAll("button"))
|
|
.find((button) => button.getAttribute("aria-label") === "Create sub-task");
|
|
expect(submitButton).not.toBeUndefined();
|
|
|
|
await act(async () => {
|
|
submitButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flush();
|
|
|
|
expect(mockIssuesApi.create).toHaveBeenCalledWith(
|
|
"company-1",
|
|
expect.objectContaining({
|
|
title: "Child issue",
|
|
parentId: "issue-1",
|
|
goalId: "goal-1",
|
|
projectId: "project-1",
|
|
assigneeAgentId: "agent-1",
|
|
}),
|
|
);
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("keeps the mobile dialog bounded with an internal flexible scroll region", async () => {
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
|
|
const dialogContent = Array.from(container.querySelectorAll("div")).find((element) =>
|
|
typeof element.className === "string" && element.className.includes("max-h-(--new-issue-dialog-height)"),
|
|
);
|
|
expect(dialogContent?.className).toContain("h-(--new-issue-dialog-height)");
|
|
expect(dialogContent?.className).toContain("overflow-hidden");
|
|
|
|
const descriptionInput = container.querySelector('textarea[aria-label="Describe a task…"]');
|
|
const bodyScrollRegion = Array.from(container.querySelectorAll("div")).find((element) =>
|
|
typeof element.className === "string" && element.className.includes("overscroll-contain"),
|
|
);
|
|
expect(bodyScrollRegion?.className).toContain("min-h-0");
|
|
expect(bodyScrollRegion?.className).toContain("overflow-y-auto");
|
|
expect(bodyScrollRegion?.contains(descriptionInput ?? null)).toBe(true);
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("tracks the mobile visual viewport and keeps the focused editor visible above the keyboard", async () => {
|
|
const visualViewport = new EventTarget() as EventTarget & {
|
|
height: number;
|
|
offsetTop: number;
|
|
};
|
|
visualViewport.height = 844;
|
|
visualViewport.offsetTop = 0;
|
|
Object.defineProperty(window, "visualViewport", {
|
|
configurable: true,
|
|
value: visualViewport,
|
|
});
|
|
Object.defineProperty(window, "innerHeight", {
|
|
configurable: true,
|
|
value: 844,
|
|
});
|
|
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
|
|
const dialogContent = Array.from(container.querySelectorAll<HTMLDivElement>("div")).find((element) =>
|
|
element.className.includes("max-h-(--new-issue-dialog-height)"),
|
|
);
|
|
const descriptionInput = container.querySelector<HTMLTextAreaElement>(
|
|
'textarea[aria-label="Describe a task…"]',
|
|
);
|
|
const scrollIntoView = vi.fn();
|
|
Object.defineProperty(descriptionInput!, "scrollIntoView", {
|
|
configurable: true,
|
|
value: scrollIntoView,
|
|
});
|
|
descriptionInput?.focus();
|
|
|
|
expect(dialogContent?.style.top).toBe("");
|
|
expect(dialogContent?.style.maxHeight).toBe("");
|
|
expect(dialogContent?.style.translate).toBe("");
|
|
|
|
visualViewport.height = 420;
|
|
visualViewport.offsetTop = 24;
|
|
await act(async () => {
|
|
visualViewport.dispatchEvent(new Event("resize"));
|
|
await new Promise((resolve) => requestAnimationFrame(resolve));
|
|
});
|
|
|
|
expect(dialogContent?.style.getPropertyValue("--new-issue-visual-viewport-height")).toBe("420px");
|
|
expect(dialogContent?.style.getPropertyValue("--new-issue-dialog-top")).toBe(
|
|
"var(--new-issue-dialog-top-gap)",
|
|
);
|
|
expect(dialogContent?.style.getPropertyValue("--new-issue-dialog-height")).toBe(
|
|
"calc(var(--new-issue-visual-viewport-height) - var(--new-issue-dialog-top-gap) - var(--new-issue-dialog-bottom-gap))",
|
|
);
|
|
expect(dialogContent?.style.top).toBe("var(--new-issue-dialog-top)");
|
|
expect(dialogContent?.style.maxHeight).toBe("var(--new-issue-dialog-height)");
|
|
expect(dialogContent?.style.translate).toBe("var(--pct-neg-50)");
|
|
expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest" });
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("ignores transient invalid visual viewport measurements and keeps the last valid layout", async () => {
|
|
const visualViewport = new EventTarget() as EventTarget & {
|
|
height: number;
|
|
offsetTop: number;
|
|
};
|
|
visualViewport.height = 0;
|
|
visualViewport.offsetTop = 0;
|
|
Object.defineProperty(window, "visualViewport", {
|
|
configurable: true,
|
|
value: visualViewport,
|
|
});
|
|
Object.defineProperty(window, "innerHeight", {
|
|
configurable: true,
|
|
value: 844,
|
|
});
|
|
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
|
|
const dialogContent = Array.from(container.querySelectorAll<HTMLDivElement>("div")).find((element) =>
|
|
element.className.includes("max-h-(--new-issue-dialog-height)"),
|
|
);
|
|
expect(dialogContent?.style.getPropertyValue("--new-issue-visual-viewport-height")).toBe("");
|
|
expect(dialogContent?.style.top).toBe("");
|
|
expect(dialogContent?.style.maxHeight).toBe("");
|
|
expect(dialogContent?.style.translate).toBe("");
|
|
|
|
visualViewport.height = 420;
|
|
visualViewport.offsetTop = 24;
|
|
await act(async () => {
|
|
visualViewport.dispatchEvent(new Event("resize"));
|
|
});
|
|
|
|
expect(dialogContent?.style.getPropertyValue("--new-issue-visual-viewport-height")).toBe("420px");
|
|
expect(dialogContent?.style.top).toBe("var(--new-issue-dialog-top)");
|
|
expect(dialogContent?.style.maxHeight).toBe("var(--new-issue-dialog-height)");
|
|
|
|
visualViewport.height = 0;
|
|
visualViewport.offsetTop = Number.NaN;
|
|
await act(async () => {
|
|
visualViewport.dispatchEvent(new Event("resize"));
|
|
});
|
|
|
|
expect(dialogContent?.style.getPropertyValue("--new-issue-visual-viewport-height")).toBe("420px");
|
|
expect(dialogContent?.style.top).toBe("var(--new-issue-dialog-top)");
|
|
expect(dialogContent?.style.maxHeight).toBe("var(--new-issue-dialog-height)");
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("hides the priority chip and mobile priority option (PAP-411)", async () => {
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
|
|
// PAP-411: priority UI is hidden behind SHOW_TASK_PRIORITY_UI (off). Neither the
|
|
// desktop priority chip nor the mobile overflow priority option should render.
|
|
const priorityChip = container.querySelector('[data-testid="new-issue-priority-chip"]');
|
|
expect(priorityChip).toBeNull();
|
|
|
|
const highPriorityOption = container.querySelector('[data-testid="new-issue-more-priority-high"]');
|
|
expect(highPriorityOption).toBeNull();
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("still submits the default priority when the priority UI is hidden (PAP-411)", async () => {
|
|
dialogState.newIssueDefaults = {
|
|
title: "Priority default persists",
|
|
};
|
|
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
|
|
const submitButton = Array.from(container.querySelectorAll("button"))
|
|
.find((button) => button.getAttribute("aria-label") === "Create task");
|
|
expect(submitButton).not.toBeUndefined();
|
|
await vi.waitFor(() => {
|
|
expect(submitButton?.hasAttribute("disabled")).toBe(false);
|
|
});
|
|
|
|
await act(async () => {
|
|
submitButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flush();
|
|
|
|
// PAP-411: the priority control is hidden, but the data-model default must survive.
|
|
expect(mockIssuesApi.create).toHaveBeenCalledWith(
|
|
"company-1",
|
|
expect.objectContaining({
|
|
title: "Priority default persists",
|
|
priority: "medium",
|
|
}),
|
|
);
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("allows editor autocomplete portal pointer events inside the modal", async () => {
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
|
|
const menu = document.createElement("div");
|
|
menu.setAttribute("data-paperclip-floating-ui", "");
|
|
const option = document.createElement("button");
|
|
menu.appendChild(option);
|
|
document.body.appendChild(menu);
|
|
const preventDefault = vi.fn();
|
|
|
|
dialogContentState.onPointerDownOutside?.({
|
|
detail: { originalEvent: { target: option } },
|
|
preventDefault,
|
|
});
|
|
|
|
expect(preventDefault).toHaveBeenCalledTimes(1);
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
|
|
|
|
it("submits the configured watchdog from a restored draft", async () => {
|
|
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
|
enableIsolatedWorkspaces: false,
|
|
});
|
|
localStorage.setItem(
|
|
"paperclip:issue-draft",
|
|
JSON.stringify({
|
|
title: "Watched task",
|
|
description: "",
|
|
status: "todo",
|
|
priority: "medium",
|
|
assigneeValue: "",
|
|
reviewerValue: "",
|
|
approverValue: "",
|
|
watchdogAgentId: "agent-9",
|
|
watchdogInstructions: "Keep it moving",
|
|
projectId: "",
|
|
assigneeModelOverride: "",
|
|
assigneeThinkingEffort: "",
|
|
assigneeChrome: false,
|
|
workMode: "standard",
|
|
}),
|
|
);
|
|
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
|
|
|
|
const submitButton = Array.from(container.querySelectorAll("button"))
|
|
.find((button) => button.getAttribute("aria-label") === "Create task");
|
|
expect(submitButton).not.toBeUndefined();
|
|
await vi.waitFor(() => {
|
|
expect(submitButton?.hasAttribute("disabled")).toBe(false);
|
|
});
|
|
|
|
await act(async () => {
|
|
submitButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flush();
|
|
|
|
expect(mockIssuesApi.create).toHaveBeenCalledWith(
|
|
"company-1",
|
|
expect.objectContaining({
|
|
title: "Watched task",
|
|
watchdog: { agentId: "agent-9", instructions: "Keep it moving" },
|
|
}),
|
|
);
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("retains a failed creation draft and allows retry through the composer", async () => {
|
|
mockIssuesApi.create.mockRejectedValueOnce(new Error("Service unavailable"));
|
|
dialogState.newIssueDefaults = { description: "Keep this request for retry" };
|
|
const { root } = renderDialog(container);
|
|
await flush();
|
|
const send = container.querySelector<HTMLButtonElement>('[aria-label="Create task"]')!;
|
|
await act(async () => { send.click(); });
|
|
await waitForAssertion(() => expect(container.querySelector('[role="alert"]')?.textContent).toContain("Service unavailable"));
|
|
expect(container.querySelector<HTMLTextAreaElement>('textarea[aria-label="Describe a task…"]')?.value).toBe("Keep this request for retry");
|
|
expect(dialogState.closeNewIssue).not.toHaveBeenCalled();
|
|
await act(async () => { send.click(); });
|
|
await waitForAssertion(() => expect(dialogState.closeNewIssue).toHaveBeenCalledOnce());
|
|
expect(mockIssuesApi.create).toHaveBeenCalledTimes(2);
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("restores shared model, effort, and fast settings into the created task", async () => {
|
|
mockAgentsApi.list.mockResolvedValue([{ id: "agent-1", name: "Coder", status: "active", adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: {} }]);
|
|
localStorage.setItem("paperclip:issue-draft", JSON.stringify({
|
|
title: "", description: "Use the saved run settings", status: "todo", priority: "medium",
|
|
assigneeValue: "agent:agent-1", projectId: "", workMode: "planning",
|
|
composerSettings: { model: "gpt-6-astra", effort: "ultra", fast: true },
|
|
}));
|
|
const { root } = renderDialog(container);
|
|
await waitForAssertion(() => expect(container.textContent).toContain("Coder"));
|
|
await act(async () => { container.querySelector<HTMLButtonElement>('[aria-label="Create task"]')!.click(); });
|
|
await waitForAssertion(() => expect(mockIssuesApi.create).toHaveBeenCalledWith("company-1", expect.objectContaining({
|
|
assigneeAgentId: "agent-1", workMode: "planning",
|
|
assigneeAdapterOverrides: { adapterConfig: { model: "gpt-6-astra", modelReasoningEffort: "ultra", fastMode: true } },
|
|
})));
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
describe("work-mode labels", () => {
|
|
function workModeOption(value: string) {
|
|
return container.querySelector(`[data-testid="composer-add-${value === "planning" ? "plan" : value}"]`);
|
|
}
|
|
|
|
it("uses the shared composer mode labels", async () => {
|
|
const { root } = renderDialog(container);
|
|
await waitForAssertion(() => {
|
|
expect(container.querySelector("[data-testid=task-chat-composer-add]")).not.toBeNull();
|
|
});
|
|
|
|
expect(container.querySelector("[data-testid=task-chat-composer-add]")).not.toBeNull();
|
|
expect(workModeOption("ask")?.textContent).toContain("Ask mode");
|
|
expect(workModeOption("planning")?.textContent).toContain("Plan mode");
|
|
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
});
|
|
|
|
});
|