Files
paperclip/ui/src/components/NewIssueDialog.test.tsx
T
Dotta c4269bab59 Add workflow interaction cancellation and issue cost summaries (#4862)
## Thinking Path

> - Paperclip coordinates work through issue-thread interactions, run
history, and cost telemetry.
> - Operators need workflow prompts to be cancellable and costs to be
visible at the issue level.
> - The earlier rollup mixed this workflow/cost work with database
backups, reliability recovery, thread scaling, and settings polish.
> - This pull request isolates the interaction and cost surfaces into a
reviewable slice.
> - The backend now supports cancelling pending question interactions
and summarizing issue-tree costs.
> - The UI component layer can render cancelled questions and interleave
activity with run ledger rows.

## What Changed

- Added `cancelled` as an issue-thread interaction status and result
shape for question interactions.
- Added the board-only `POST
/issues/:id/interactions/:interactionId/cancel` route and service
implementation.
- Added issue-tree cost summary support in the cost service and
`/issues/:id/cost-summary` API route.
- Extended shared cost exports and UI API/query keys for issue cost
summaries.
- Updated `IssueThreadInteractionCard` and `IssueRunLedger` components
for cancelled questions, issue cost surfaces, and activity/run
interleaving.
- Added focused server and component regression coverage.

## Verification

- `pnpm install --frozen-lockfile`
- `pnpm exec vitest run server/src/__tests__/costs-service.test.ts
server/src/__tests__/issue-thread-interaction-routes.test.ts
server/src/__tests__/issue-thread-interactions-service.test.ts
ui/src/components/IssueRunLedger.test.tsx`
- Result: 4 test files passed, 45 tests passed.
- UI screenshots not included because this PR updates reusable
components and API surfaces without wiring a new page-level layout.

## Risks

- Adds a new interaction terminal status; clients that switch
exhaustively on interaction status may need to handle `cancelled`.
- Issue-tree cost summaries use recursive issue traversal and should be
watched on unusually large issue trees.
- Page-level issue detail wiring is intentionally left to the board
QoL/issue-detail branch to keep this PR narrow.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5.5, code execution and GitHub CLI tool use, medium
reasoning effort.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-04-30 13:57:25 -05:00

600 lines
17 KiB
TypeScript

// @vitest-environment jsdom
import { act } from "react";
import type { ComponentProps, ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { NewIssueDialog } from "./NewIssueDialog";
const dialogState = vi.hoisted(() => ({
newIssueOpen: true,
newIssueDefaults: {} as Record<string, unknown>,
closeNewIssue: vi.fn(),
}));
const companyState = vi.hoisted(() => ({
companies: [
{
id: "company-1",
name: "Paperclip",
status: "active",
brandColor: "#123456",
issuePrefix: "PAP",
},
],
selectedCompanyId: "company-1",
selectedCompany: {
id: "company-1",
name: "Paperclip",
status: "active",
brandColor: "#123456",
issuePrefix: "PAP",
},
}));
const toastState = vi.hoisted(() => ({
pushToast: vi.fn(),
}));
const mockIssuesApi = vi.hoisted(() => ({
create: vi.fn(),
upsertDocument: vi.fn(),
uploadAttachment: vi.fn(),
}));
const mockExecutionWorkspacesApi = vi.hoisted(() => ({
list: 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(),
}));
vi.mock("../context/DialogContext", () => ({
useDialog: () => dialogState,
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => companyState,
}));
vi.mock("../context/ToastContext", () => ({
useToastActions: () => toastState,
}));
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("../hooks/useProjectOrder", () => ({
useProjectOrder: ({ projects }: { projects: unknown[] }) => ({
orderedProjects: projects,
}),
}));
vi.mock("../lib/recent-assignees", () => ({
getRecentAssigneeIds: () => [],
sortAgentsByRecency: (agents: unknown[]) => agents,
trackRecentAssignee: vi.fn(),
}));
vi.mock("../lib/assignees", () => ({
assigneeValueFromSelection: ({
assigneeAgentId,
assigneeUserId,
}: {
assigneeAgentId?: string;
assigneeUserId?: string;
}) => assigneeAgentId ? `agent:${assigneeAgentId}` : assigneeUserId ? `user:${assigneeUserId}` : "",
currentUserAssigneeOption: () => [],
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;
renderTriggerValue?: (option: { id: string; label: string } | null) => ReactNode;
}
>(function InlineEntitySelectorMock({ value, placeholder, renderTriggerValue }, ref) {
return (
<button ref={ref} type="button">
{(renderTriggerValue?.(value ? { id: value, label: value } : null) ?? value) || placeholder}
</button>
);
}),
};
});
vi.mock("./AgentIconPicker", () => ({
AgentIcon: () => null,
}));
vi.mock("@/components/ui/dialog", () => ({
Dialog: ({ open, children }: { open: boolean; children: ReactNode }) => (open ? <div>{children}</div> : null),
DialogContent: ({
children,
showCloseButton: _showCloseButton,
onEscapeKeyDown: _onEscapeKeyDown,
onPointerDownOutside: _onPointerDownOutside,
...props
}: ComponentProps<"div"> & {
showCloseButton?: boolean;
onEscapeKeyDown?: (event: unknown) => void;
onPointerDownOutside?: (event: unknown) => void;
}) => <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/popover", () => ({
Popover: ({ children }: { children: ReactNode }) => <div>{children}</div>,
PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
PopoverContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function flush() {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
async function typeTextareaValue(textarea: HTMLTextAreaElement, value: string) {
await act(async () => {
const valueSetter = Object.getOwnPropertyDescriptor(
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) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const root = createRoot(container);
act(() => {
root.render(
<QueryClientProvider client={queryClient}>
<NewIssueDialog />
</QueryClientProvider>,
);
});
return { root, queryClient };
}
describe("NewIssueDialog", () => {
let container: HTMLDivElement;
beforeEach(() => {
vi.useRealTimers();
container = document.createElement("div");
document.body.appendChild(container);
dialogState.newIssueOpen = true;
dialogState.newIssueDefaults = {};
dialogState.closeNewIssue.mockReset();
toastState.pushToast.mockReset();
mockIssuesApi.create.mockReset();
mockIssuesApi.upsertDocument.mockReset();
mockIssuesApi.uploadAttachment.mockReset();
mockExecutionWorkspacesApi.list.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 });
localStorage.clear();
mockIssuesApi.create.mockResolvedValue({
id: "issue-2",
companyId: "company-1",
identifier: "PAP-2",
});
});
afterEach(() => {
document.body.innerHTML = "";
});
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-issue");
expect(container.textContent).toContain("Sub-issue of");
expect(container.textContent).toContain("PAP-1");
expect(container.textContent).toContain("Parent issue");
expect(container.textContent).toContain("Create Sub-Issue");
act(() => root.unmount());
dialogState.newIssueDefaults = {};
const rerendered = renderDialog(container);
await flush();
expect(container.textContent).toContain("New issue");
expect(container.textContent).toContain("Create Issue");
expect(container.textContent).not.toContain("Sub-issue of");
act(() => rerendered.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.list.mockResolvedValue([
{
id: "workspace-1",
name: "Parent workspace",
status: "active",
branchName: "feature/pap-1",
cwd: "/tmp/workspace-1",
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();
const submitButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Create Sub-Issue"));
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",
}),
);
act(() => root.unmount());
});
it("submits the latest locally typed title and description", 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;
}));
const { root } = renderDialog(container);
await flush();
const titleInput = container.querySelector('textarea[placeholder="Issue title"]') as HTMLTextAreaElement | null;
const descriptionInput = container.querySelector('textarea[aria-label="Add description..."]') as HTMLTextAreaElement | null;
expect(titleInput).not.toBeNull();
expect(descriptionInput).not.toBeNull();
await typeTextareaValue(titleInput!, "Typed issue");
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.textContent?.includes("Create Issue"));
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",
}),
);
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.textContent?.includes("Create Sub-Issue"));
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-[calc(100dvh-2rem)]"),
);
expect(dialogContent?.className).toContain("h-[calc(100dvh-2rem)]");
expect(dialogContent?.className).toContain("overflow-hidden");
const titleInput = container.querySelector('textarea[placeholder="Issue title"]');
const descriptionInput = container.querySelector('textarea[aria-label="Add description..."]');
const bodyScrollRegion = Array.from(container.querySelectorAll("div")).find((element) =>
typeof element.className === "string" && element.className.includes("overscroll-contain"),
);
expect(bodyScrollRegion?.className).toContain("flex-1");
expect(bodyScrollRegion?.className).toContain("overflow-y-auto");
expect(bodyScrollRegion?.contains(titleInput ?? null)).toBe(true);
expect(bodyScrollRegion?.contains(descriptionInput ?? null)).toBe(true);
act(() => root.unmount());
});
it("warns when a sub-issue stops matching the parent workspace", async () => {
mockProjectsApi.list.mockResolvedValue([
{
id: "project-1",
name: "Alpha",
description: null,
archivedAt: null,
color: "#445566",
executionWorkspacePolicy: {
enabled: true,
defaultMode: "shared_workspace",
},
},
]);
mockExecutionWorkspacesApi.list.mockResolvedValue([
{
id: "workspace-1",
name: "Parent workspace",
status: "active",
branchName: "feature/pap-1",
cwd: "/tmp/workspace-1",
lastUsedAt: new Date("2026-04-06T16:00:00.000Z"),
},
{
id: "workspace-2",
name: "Other workspace",
status: "active",
branchName: "feature/pap-2",
cwd: "/tmp/workspace-2",
lastUsedAt: new Date("2026-04-06T16:01: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",
parentExecutionWorkspaceLabel: "Parent workspace",
goalId: "goal-1",
};
const { root } = renderDialog(container);
await flush();
await flush();
expect(container.textContent).not.toContain("will no longer use the parent issue workspace");
const selects = Array.from(container.querySelectorAll("select"));
const modeSelect = selects[0] as HTMLSelectElement | undefined;
expect(modeSelect).not.toBeUndefined();
await act(async () => {
modeSelect!.value = "shared_workspace";
modeSelect!.dispatchEvent(new Event("change", { bubbles: true }));
});
await flush();
expect(container.textContent).toContain("will no longer use the parent issue workspace");
expect(container.textContent).toContain("Parent workspace");
act(() => root.unmount());
});
});