diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx
new file mode 100644
index 00000000..4c3a4f72
--- /dev/null
+++ b/ui/src/components/IssueProperties.test.tsx
@@ -0,0 +1,204 @@
+// @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 type { Issue } from "@paperclipai/shared";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { IssueProperties } from "./IssueProperties";
+
+const mockAgentsApi = vi.hoisted(() => ({
+ list: vi.fn(),
+}));
+
+const mockProjectsApi = vi.hoisted(() => ({
+ list: vi.fn(),
+}));
+
+const mockIssuesApi = vi.hoisted(() => ({
+ listLabels: vi.fn(),
+}));
+
+const mockAuthApi = vi.hoisted(() => ({
+ getSession: vi.fn(),
+}));
+
+vi.mock("../context/CompanyContext", () => ({
+ useCompany: () => ({
+ selectedCompanyId: "company-1",
+ }),
+}));
+
+vi.mock("../api/agents", () => ({
+ agentsApi: mockAgentsApi,
+}));
+
+vi.mock("../api/projects", () => ({
+ projectsApi: mockProjectsApi,
+}));
+
+vi.mock("../api/issues", () => ({
+ issuesApi: mockIssuesApi,
+}));
+
+vi.mock("../api/auth", () => ({
+ authApi: mockAuthApi,
+}));
+
+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", () => ({
+ formatAssigneeUserLabel: () => "Me",
+}));
+
+vi.mock("./StatusIcon", () => ({
+ StatusIcon: ({ status }: { status: string }) => {status},
+}));
+
+vi.mock("./PriorityIcon", () => ({
+ PriorityIcon: ({ priority }: { priority: string }) => {priority},
+}));
+
+vi.mock("./Identity", () => ({
+ Identity: ({ name }: { name: string }) => {name},
+}));
+
+vi.mock("./AgentIconPicker", () => ({
+ AgentIcon: () => null,
+}));
+
+vi.mock("@/lib/router", () => ({
+ Link: ({ children, to, ...props }: { children: ReactNode; to: string } & ComponentProps<"a">) => {children},
+}));
+
+vi.mock("@/components/ui/separator", () => ({
+ Separator: () =>
,
+}));
+
+vi.mock("@/components/ui/popover", () => ({
+ Popover: ({ children }: { children: ReactNode }) => {children}
,
+ PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}>,
+ PopoverContent: ({ children }: { children: ReactNode }) => {children}
,
+}));
+
+// 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));
+ });
+}
+
+function createIssue(overrides: Partial = {}): Issue {
+ return {
+ id: "issue-1",
+ companyId: "company-1",
+ projectId: null,
+ projectWorkspaceId: null,
+ goalId: null,
+ parentId: null,
+ title: "Parent issue",
+ description: null,
+ status: "todo",
+ priority: "medium",
+ assigneeAgentId: null,
+ assigneeUserId: null,
+ checkoutRunId: null,
+ executionRunId: null,
+ executionAgentNameKey: null,
+ executionLockedAt: null,
+ createdByAgentId: null,
+ createdByUserId: "user-1",
+ issueNumber: 1,
+ identifier: "PAP-1",
+ requestDepth: 0,
+ billingCode: null,
+ assigneeAdapterOverrides: null,
+ executionWorkspaceId: null,
+ executionWorkspacePreference: null,
+ executionWorkspaceSettings: null,
+ startedAt: null,
+ completedAt: null,
+ cancelledAt: null,
+ hiddenAt: null,
+ labels: [],
+ labelIds: [],
+ blockedBy: [],
+ blocks: [],
+ createdAt: new Date("2026-04-06T12:00:00.000Z"),
+ updatedAt: new Date("2026-04-06T12:05:00.000Z"),
+ ...overrides,
+ };
+}
+
+function renderProperties(container: HTMLDivElement, props: ComponentProps) {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ },
+ });
+ const root = createRoot(container);
+ act(() => {
+ root.render(
+
+
+ ,
+ );
+ });
+ return root;
+}
+
+describe("IssueProperties", () => {
+ let container: HTMLDivElement;
+
+ beforeEach(() => {
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ mockAgentsApi.list.mockResolvedValue([]);
+ mockProjectsApi.list.mockResolvedValue([]);
+ mockIssuesApi.listLabels.mockResolvedValue([]);
+ mockAuthApi.getSession.mockResolvedValue({ user: { id: "user-1" } });
+ });
+
+ afterEach(() => {
+ document.body.innerHTML = "";
+ });
+
+ it("always exposes the add sub-issue action", async () => {
+ const onAddSubIssue = vi.fn();
+ const root = renderProperties(container, {
+ issue: createIssue(),
+ childIssues: [],
+ onAddSubIssue,
+ onUpdate: vi.fn(),
+ });
+ await flush();
+
+ expect(container.textContent).toContain("Sub-issues");
+ expect(container.textContent).toContain("Add sub-issue");
+
+ const addButton = Array.from(container.querySelectorAll("button"))
+ .find((button) => button.textContent?.includes("Add sub-issue"));
+ expect(addButton).not.toBeUndefined();
+
+ await act(async () => {
+ addButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+
+ expect(onAddSubIssue).toHaveBeenCalledTimes(1);
+
+ act(() => root.unmount());
+ });
+});
diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx
index 6c204eab..397f930c 100644
--- a/ui/src/components/IssueProperties.tsx
+++ b/ui/src/components/IssueProperties.tsx
@@ -72,6 +72,8 @@ function defaultExecutionWorkspaceModeForProject(project: { executionWorkspacePo
interface IssuePropertiesProps {
issue: Issue;
+ childIssues?: Issue[];
+ onAddSubIssue?: () => void;
onUpdate: (data: Record) => void;
inline?: boolean;
}
@@ -147,7 +149,13 @@ function PropertyPicker({
);
}
-export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProps) {
+export function IssueProperties({
+ issue,
+ childIssues = [],
+ onAddSubIssue,
+ onUpdate,
+ inline,
+}: IssuePropertiesProps) {
const { selectedCompanyId } = useCompany();
const queryClient = useQueryClient();
const companyId = issue.companyId ?? selectedCompanyId;
@@ -713,6 +721,34 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
)}
+
+
+ {childIssues.length > 0 ? (
+ childIssues.map((child) => (
+
+ {child.identifier ?? child.title}
+
+ ))
+ ) : (
+
None
+ )}
+ {onAddSubIssue ? (
+
+ ) : null}
+
+
+
{issue.parentId && (
({
+ newIssueOpen: true,
+ newIssueDefaults: {} as Record,
+ 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", () => ({
+ useToast: () => 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 (
+