Files
PaperClipAI/ui/src/components/NewIssueDialog.test.tsx
T
DottaandPaperclip 141f202e40 Clean up experimental settings features (#12681)
## Thinking Path

> - Paperclip is the open source app that people use to manage AI agents
for work.
> - Instance settings control optional product features and developer
tools.
> - The experimental settings page mixed active experiments, internal
tools, and old recovery controls.
> - Some workspace links also used the selected company instead of the
workspace owner.
> - These problems made settings hard to scan and could send users to
the wrong company route.
> - This pull request removes old controls, groups developer settings,
and resolves workspace links from workspace data.
> - The benefit is a smaller settings surface and correct workspace
navigation.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

This improves the instance experimental settings page, task watchdog
controls, dependency wake recovery, and execution workspace routes.

**Current behavior**

The settings page shows old recovery controls and mixes product
experiments with internal developer settings. Task watchdogs require an
extra feature flag. Some direct workspace links use the current company
prefix instead of the company that owns the workspace.

**Proposed behavior**

Remove the old task recovery experiment and its unused API surface. Make
task watchdog controls available without the removed flag. Put worktree
execution and managed environment controls in the developer section.
Resolve direct workspace links from the workspace owner and reject a
company prefix that does not own the workspace.

**Reason and benefit**

The smaller settings page is easier to understand. The server keeps only
the dependency wake backstop that it still uses. Workspace links open
under the correct company route.

**Breaking changes**

This removes the experimental issue graph recovery preview and run
endpoints. It also removes the task watchdog feature flag. Task watchdog
data and dependency wake behavior remain available.

## What Changed

- Removed the old task watchdog and issue graph recovery feature flags.
- Removed the old issue graph recovery preview, run controls, API
contracts, and unused recovery implementation.
- Kept resolved dependency wakes as the scheduler backstop.
- Grouped product experiments and Paperclip developer settings on the
instance settings page.
- Made task watchdog controls available without an extra experimental
flag.
- Added owner-aware redirects and company checks for execution workspace
routes.
- Hid the false stopped-state badge while a workspace has no active
runtime state.
- Updated focused server and UI tests for the new behavior.

## Verification

- `pnpm check:token-gates`
- `pnpm -r typecheck`
- `pnpm build`
- `pnpm test:run` completed with 5,620 passing tests and four failures
in unchanged workspace runtime port tests. The same four failures repeat
when the two files run alone.
- The complete GitHub CI matrix passed, including all server, serialized
server, build, canary, and end-to-end jobs.

## Risks

- Clients that call the removed experimental recovery endpoints must
stop calling them.
- The route checks depend on workspace detail access. An unknown or
cross-company workspace returns the global not-found page.
- There are no database migrations, lockfile changes, workflow changes,
or design image changes.

> 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 with GPT-5. The exact deployment ID and context window are
not exposed. Reasoning, tool use, and code execution were enabled.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-09-01 14:23:05 -05:00

1492 lines
46 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 { 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 mockIssuesApi = vi.hoisted(() => ({
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/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("../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("../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,
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/popover", () => ({
Popover: ({ children }: { children: ReactNode }) => <div>{children}</div>,
PopoverTrigger: ({ 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, 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;
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();
mockIssuesApi.create.mockReset();
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 = "";
});
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.textContent).toContain("Create Sub-Task");
act(() => root.unmount());
dialogState.newIssueDefaults = {};
const rerendered = renderDialog(container);
await flush();
expect(container.textContent).toContain("New task");
expect(container.textContent).toContain("Create Task");
expect(container.textContent).not.toContain("Sub-task 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.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",
projectWorkspaceId: undefined,
reuseEligible: true,
});
});
expect(mockExecutionWorkspacesApi.list).not.toHaveBeenCalled();
const submitButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("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("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-issue-work-mode="planning"]');
expect(planningButton?.className).toContain("bg-accent");
const submitButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("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-issue-work-mode="ask"]');
expect(askButton?.className).toContain("bg-accent");
const submitButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("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("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 } = renderDialog(container);
await flush();
expect(container.textContent).toContain("New task");
expect(container.textContent).not.toContain("New sub-task");
await waitForAssertion(() => {
expect(container.textContent).toContain("Reusing PAP-100");
});
const submitButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("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());
});
it("keeps the reusable workspace search popover inside the modal", async () => {
mockProjectsApi.list.mockResolvedValue([
{
id: "project-1",
name: "Alpha",
description: null,
archivedAt: null,
color: "#445566",
workspaces: [
{
id: "project-workspace-1",
name: "Primary",
isPrimary: true,
},
],
executionWorkspacePolicy: {
enabled: true,
defaultMode: "shared_workspace",
},
},
]);
mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([
{
id: "workspace-1",
name: "PAP-11446-on-mobile-the-agent-chat",
mode: "isolated_workspace",
status: "active",
branchName: "PAP-11446-on-mobile-the-agent-chat",
cwd: "/tmp/workspace-1",
projectWorkspaceId: "project-workspace-1",
lastUsedAt: new Date("2026-04-06T16:00:00.000Z"),
},
]);
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
dialogState.newIssueDefaults = {
title: "Follow-up issue",
projectId: "project-1",
executionWorkspaceId: "workspace-1",
};
const { root } = renderDialog(container);
await flush();
await waitForAssertion(() => {
const workspaceInput = container.querySelector('input[placeholder="Search workspaces..."]');
expect(workspaceInput?.closest("[data-disable-portal]")?.getAttribute("data-disable-portal")).toBe("true");
});
act(() => root.unmount());
});
it("submits the latest locally typed title and description", async () => {
let resolveProjects: (projects: Array<{
id: string;
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="Task 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 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.textContent?.includes("Create Task"));
expect(submitButton).not.toBeUndefined();
await act(async () => {
submitButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(submitButton?.textContent).toContain("Creating...");
expect(submitButton?.getAttribute("aria-busy")).toBe("true");
expect(container.textContent).not.toContain("Creating issue");
act(() => root.unmount());
});
it("submits Chinese, Japanese, and Hindi issue text without normalization", async () => {
const title = "验证中文任务";
const description = [
"请用中文回复。",
"日本語: 次の手順を書いてください。",
"हिन्दी: कृपया स्थिति बताएं।",
].join("\n");
const { root } = renderDialog(container);
await flush();
const titleInput = container.querySelector('textarea[placeholder="Task 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!, title);
await typeTextareaValue(descriptionInput!, description);
const submitButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("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[placeholder="Task title"]') as HTMLTextAreaElement | null;
expect(titleInput).not.toBeNull();
await typeTextareaValue(titleInput!, "Plan this first");
const planningButton = container.querySelector('[data-issue-work-mode="planning"]');
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.textContent?.includes("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: "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[placeholder="Task title"]') as HTMLTextAreaElement | null;
expect(titleInput).not.toBeNull();
await typeTextareaValue(titleInput!, "Answer this first");
const askButton = container.querySelector('[data-issue-work-mode="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.textContent?.includes("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: "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-issue-work-mode-chip]");
expect(modeChip()?.getAttribute("data-issue-work-mode-chip")).toBe("standard");
expect(modeChip()?.textContent).toContain("Auto mode");
await act(async () => {
modeChip()?.dispatchEvent(new KeyboardEvent("keydown", {
bubbles: true,
code: "",
key: ".",
metaKey: true,
}));
});
expect(modeChip()?.getAttribute("data-issue-work-mode-chip")).toBe("planning");
expect(modeChip()?.textContent).toContain("Plan mode");
await act(async () => {
modeChip()?.dispatchEvent(new KeyboardEvent("keydown", {
bubbles: true,
code: "Period",
key: ".",
metaKey: true,
}));
});
expect(modeChip()?.getAttribute("data-issue-work-mode-chip")).toBe("ask");
expect(modeChip()?.textContent).toContain("Ask mode");
await act(async () => {
modeChip()?.dispatchEvent(new KeyboardEvent("keydown", {
bubbles: true,
code: "Period",
key: ".",
metaKey: true,
}));
});
expect(modeChip()?.getAttribute("data-issue-work-mode-chip")).toBe("standard");
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-issue-work-mode-chip]");
expect(modeChip()?.getAttribute("data-issue-work-mode-chip")).toBe("standard");
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-issue-work-mode-chip")).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-issue-work-mode-chip")).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-issue-work-mode-chip")).toBe("planning");
act(() => root.unmount());
});
it("cycles work modes with ctrl-period", async () => {
const { root } = renderDialog(container);
await flush();
const modeChip = () => container.querySelector("[data-issue-work-mode-chip]");
expect(modeChip()?.getAttribute("data-issue-work-mode-chip")).toBe("standard");
await act(async () => {
modeChip()?.dispatchEvent(new KeyboardEvent("keydown", {
bubbles: true,
code: "Period",
key: ".",
ctrlKey: true,
}));
});
expect(modeChip()?.getAttribute("data-issue-work-mode-chip")).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.textContent?.includes("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 titleInput = container.querySelector('textarea[placeholder="Task 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("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="Add description..."]',
);
const scrollIntoView = vi.fn();
Object.defineProperty(descriptionInput!, "scrollIntoView", {
configurable: true,
value: scrollIntoView,
});
descriptionInput?.focus();
expect(dialogContent?.style.top).toBe("");
expect(dialogContent?.style.height).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-visual-viewport-offset-top")).toBe("24px");
expect(dialogContent?.style.getPropertyValue("--new-issue-dialog-top")).toBe(
"calc(var(--new-issue-visual-viewport-offset-top) + 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.height).toBe("var(--new-issue-dialog-height)");
expect(dialogContent?.style.translate).toBe("var(--pct-neg-50)");
expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest" });
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.textContent?.includes("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("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.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"),
},
{
id: "workspace-2",
name: "Other workspace",
mode: "isolated_workspace",
status: "active",
branchName: "feature/pap-2",
cwd: "/tmp/workspace-2",
projectWorkspaceId: null,
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 task 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 task workspace");
expect(container.textContent).toContain("Parent workspace");
act(() => root.unmount());
});
it("reveals the watchdog editor from the overflow menu", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIsolatedWorkspaces: false,
});
const { root } = renderDialog(container);
await flush();
// The watchdog row is hidden until the menu item is toggled on.
expect(container.querySelector('textarea[placeholder^="What should the watchdog"]')).toBeNull();
const watchdogMenuItem = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Watchdog");
expect(watchdogMenuItem).not.toBeUndefined();
await act(async () => {
watchdogMenuItem!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(container.textContent).toContain("Set watchdog");
expect(container.querySelector('textarea[placeholder^="What should the watchdog"]')).not.toBeNull();
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();
expect(container.textContent).toContain("Keep it moving");
const submitButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("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());
});
describe("graduated work-mode labels and status hues", () => {
function workModeOption(value: string) {
return container.querySelector(`[data-issue-work-mode="${value}"]`);
}
function statusOptionIconClass(label: string, description?: string) {
const button = Array.from(container.querySelectorAll("button")).find((candidate) => {
const text = candidate.textContent ?? "";
return (
candidate.querySelector("svg") !== null &&
text.includes(label) &&
(description === undefined || text.includes(description))
);
});
return button?.querySelector("svg")?.getAttribute("class") ?? "";
}
it("uses auto-mode labels and brand status hues by default", async () => {
const { root } = renderDialog(container);
await waitForAssertion(() => {
expect(workModeOption("standard")?.textContent).toContain("Auto mode");
});
expect(workModeOption("standard")?.textContent).toContain("Auto mode");
expect(workModeOption("ask")?.textContent).toContain("Ask mode");
expect(workModeOption("planning")?.textContent).toContain("Plan mode");
expect(statusOptionIconClass("Todo", "Executable - assignee will be woken")).toContain("text-amber-600");
expect(statusOptionIconClass("In Progress")).toContain("text-blue-600");
act(() => root.unmount());
});
});
describe("PAP-8501: company badge shows issuePrefix", () => {
it("displays issuePrefix instead of name-derived prefix", async () => {
// Override company data to have mismatched name/prefix
companyState.companies = [
{
id: "company-1",
name: "Acme Labs",
status: "active",
issuePrefix: "OPS",
},
];
companyState.selectedCompany = {
id: "company-1",
name: "Acme Labs",
status: "active",
issuePrefix: "OPS",
};
const { root } = renderDialog(container);
await waitForAssertion(() => {
const text = container.textContent ?? "";
// Should show OPS (issuePrefix), not ACM (name.slice(0,3))
expect(text).toContain("OPS");
});
act(() => root.unmount());
});
});
});