paperclip/ui/src/pages/IssueDetail.test.tsx

6069 lines
182 KiB
TypeScript

import { TaskChatPausedTakeover, type TaskComposerPause } from "../components/task-chat/TaskChatPausedTakeover";
// @vitest-environment jsdom
import { RichWorkProductCard } from "../components/task-chat/RichWorkProductCard";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type {
Agent,
Issue,
IssueAttachment,
IssueComment,
IssueQueuedCommentQueue,
RequestConfirmationInteraction,
IssueTreeControlPreview,
IssueTreeHold,
IssueWorkProduct,
} from "@paperclipai/shared";
import { ONBOARDING_FIRST_TASK_ORIGIN_KIND } from "@paperclipai/shared";
import type {
AnchorHTMLAttributes,
ButtonHTMLAttributes,
ReactElement,
ReactNode,
} from "react";
import { NavigationType } from "react-router-dom";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TooltipProvider } from "@/components/ui/tooltip";
import {
canBoardManageRuntime,
canBoardResolveRecoveryAction,
IssueDetail,
readRecoveryReconcileWorkspaceId,
shouldScrollIssueDetailToTopOnNavigation,
} from "./IssueDetail";
import { queryKeys } from "../lib/queryKeys";
import {
armIssueDetailInboxQuickArchive,
createIssueDetailLocationState,
} from "../lib/issueDetailBreadcrumb";
import { getRecentTasksStorageKey, readRecentTasks } from "../lib/recent-tasks";
import { ApiError } from "../api/client";
const mockIssuesApi = vi.hoisted(() => ({
get: vi.fn(),
list: vi.fn(),
listAll: vi.fn(),
listAcceptedPlanDecompositions: vi.fn(),
listComments: vi.fn(),
listAttachments: vi.fn(),
listWorkProducts: vi.fn(),
listFeedbackVotes: vi.fn(),
listInteractions: vi.fn(),
getQueuedComments: vi.fn(),
interruptQueuedComments: vi.fn(),
editQueuedComment: vi.fn(),
reorderQueuedComments: vi.fn(),
steerQueuedComment: vi.fn(),
discardQueuedComment: vi.fn(),
markRead: vi.fn(),
update: vi.fn(),
resolveRecoveryAction: vi.fn(),
previewTreeControl: vi.fn(),
getTreeControlState: vi.fn(),
listTreeHolds: vi.fn(),
createTreeHold: vi.fn(),
releaseTreeHold: vi.fn(),
archiveFromInbox: vi.fn(),
unarchiveFromInbox: vi.fn(),
addComment: vi.fn(),
cancelComment: vi.fn(),
upsertFeedbackVote: vi.fn(),
uploadAttachment: vi.fn(),
deleteAttachment: vi.fn(),
upsertDocument: vi.fn(),
getDocument: vi.fn(),
rejectInteraction: vi.fn(),
}));
const mockActivityApi = vi.hoisted(() => ({
forIssue: vi.fn(),
runsForIssue: vi.fn(),
}));
const mockHeartbeatsApi = vi.hoisted(() => ({
liveRunsForIssue: vi.fn(),
activeRunForIssue: vi.fn(),
cancel: vi.fn(),
}));
const mockAgentsApi = vi.hoisted(() => ({
list: vi.fn(),
}));
const mockAccessApi = vi.hoisted(() => ({
getCurrentBoardAccess: vi.fn(),
listUserDirectory: vi.fn(),
}));
const mockAuthApi = vi.hoisted(() => ({
getSession: vi.fn(),
}));
const mockProjectsApi = vi.hoisted(() => ({
list: vi.fn(),
}));
const mockDecisionsApi = vi.hoisted(() => ({
list: vi.fn(),
}));
const mockInstanceSettingsApi = vi.hoisted(() => ({
getGeneral: vi.fn(),
getExperimental: vi.fn(),
}));
const mockNavigate = vi.hoisted(() => vi.fn());
const mockLocation = vi.hoisted(() => ({
pathname: "/issues/PAP-1",
search: "",
hash: "",
state: null as unknown,
}));
const mockOpenPanel = vi.hoisted(() => vi.fn());
const mockClosePanel = vi.hoisted(() => vi.fn());
const mockSetPanelVisible = vi.hoisted(() => vi.fn());
const mockRequestPanelMaximize = vi.hoisted(() => vi.fn());
const mockClearPanelMaximizeRequest = vi.hoisted(() => vi.fn());
const mockPanelState = vi.hoisted(() => ({ panelVisible: true }));
const mockRouteParams = vi.hoisted(() => ({
issueId: "PAP-1",
companyPrefix: "PAP",
}));
const mockSidebarState = vi.hoisted(() => ({ isMobile: false }));
const mockIssuePropertiesRender = vi.hoisted(() => vi.fn());
const mockTaskSidePanelRender = vi.hoisted(() => vi.fn());
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
const mockSetBreadcrumbToolbar = vi.hoisted(() => vi.fn());
const mockSetBreadcrumbPanelControl = vi.hoisted(() => vi.fn());
const mockSetMobileToolbar = vi.hoisted(() => vi.fn());
const mockPushToast = vi.hoisted(() => vi.fn());
const mockIssuesListRender = vi.hoisted(() => vi.fn());
const mockIssueChatThreadRender = vi.hoisted(() => vi.fn());
const mockImageGalleryRender = vi.hoisted(() => vi.fn());
const mockIssueWorkspaceCardRender = vi.hoisted(() => vi.fn());
const DIRECT_ADAPTER_TYPES = [
"codex_local",
"claude_local",
"opencode_local",
"process",
"http",
"custom_plugin",
] as const;
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
(globalThis as any).ResizeObserver =
(globalThis as any).ResizeObserver ?? ResizeObserverStub;
vi.mock("../api/issues", () => ({
issuesApi: mockIssuesApi,
}));
vi.mock("../api/activity", () => ({
activityApi: mockActivityApi,
}));
vi.mock("../api/heartbeats", () => ({
heartbeatsApi: mockHeartbeatsApi,
}));
vi.mock("../api/approvals", () => ({
approvalsApi: {
approve: vi.fn(),
reject: vi.fn(),
},
}));
vi.mock("../api/agents", () => ({
agentsApi: mockAgentsApi,
}));
vi.mock("../api/access", () => ({
accessApi: mockAccessApi,
}));
vi.mock("../api/auth", () => ({
authApi: mockAuthApi,
}));
vi.mock("../api/projects", () => ({
projectsApi: mockProjectsApi,
}));
vi.mock("../api/decisions", () => ({
decisionsApi: mockDecisionsApi,
}));
vi.mock("../api/instanceSettings", () => ({
instanceSettingsApi: mockInstanceSettingsApi,
}));
vi.mock("@/lib/router", () => ({
Link: ({
children,
to,
state: _state,
issuePrefetch: _issuePrefetch,
issueQuicklookSide: _issueQuicklookSide,
issueQuicklookAlign: _issueQuicklookAlign,
...props
}: {
children?: ReactNode;
to: string;
state?: unknown;
issuePrefetch?: unknown;
issueQuicklookSide?: unknown;
issueQuicklookAlign?: unknown;
} & AnchorHTMLAttributes<HTMLAnchorElement>) => (
<a href={to} {...props}>
{children}
</a>
),
useLocation: () => mockLocation,
useNavigate: () => mockNavigate,
useNavigationType: () => "PUSH",
useParams: () => ({ ...mockRouteParams }),
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({
companies: [
{
id: "company-1",
name: "Paperclip",
issuePrefix: "PAP",
status: "active",
},
],
selectedCompanyId: "company-1",
selectedCompany: {
id: "company-1",
name: "Paperclip",
issuePrefix: "PAP",
status: "active",
},
selectionSource: "manual",
loading: false,
error: null,
setSelectedCompanyId: vi.fn(),
reloadCompanies: vi.fn(),
createCompany: vi.fn(),
}),
}));
const mockOpenNewIssue = vi.hoisted(() => vi.fn());
const mockOpenNewProject = vi.hoisted(() => vi.fn());
const mockOpenNewGoal = vi.hoisted(() => vi.fn());
vi.mock("../context/DialogContext", () => ({
useDialog: () => ({
openNewIssue: mockOpenNewIssue,
}),
useDialogActions: () => ({
openNewIssue: mockOpenNewIssue,
openNewProject: mockOpenNewProject,
openNewGoal: mockOpenNewGoal,
}),
}));
vi.mock("../context/PanelContext", () => ({
usePanel: () => ({
openPanel: mockOpenPanel,
closePanel: mockClosePanel,
panelVisible: mockPanelState.panelVisible,
setPanelVisible: mockSetPanelVisible,
requestPanelMaximize: mockRequestPanelMaximize,
clearPanelMaximizeRequest: mockClearPanelMaximizeRequest,
}),
}));
vi.mock("../context/SidebarContext", () => ({
useSidebar: () => mockSidebarState,
}));
vi.mock("../context/BreadcrumbContext", () => ({
useBreadcrumbs: () => ({
setBreadcrumbs: mockSetBreadcrumbs,
setBreadcrumbToolbar: mockSetBreadcrumbToolbar,
setBreadcrumbPanelControl: mockSetBreadcrumbPanelControl,
setMobileToolbar: mockSetMobileToolbar,
}),
}));
vi.mock("../context/ToastContext", () => ({
useToastActions: () => ({
pushToast: mockPushToast,
}),
}));
vi.mock("../hooks/useProjectOrder", () => ({
useProjectOrder: ({ projects }: { projects: unknown[] }) => ({
orderedProjects: projects,
}),
}));
vi.mock("@/plugins/slots", () => ({
PluginSlotMount: () => null,
PluginSlotOutlet: () => null,
usePluginSlots: () => ({ slots: [], isLoading: false, errorMessage: null }),
}));
vi.mock("@/plugins/launchers", () => ({
PluginLauncherOutlet: () => null,
}));
vi.mock("../components/InlineEditor", () => ({
InlineEditor: ({
value,
placeholder,
}: {
value?: string;
placeholder?: string;
}) => <div>{value || placeholder}</div>,
}));
vi.mock("../components/IssueChatThread", () => ({
IssueChatThread: (props: {
onWorkModeChange?: (workMode: string) => void;
issueWorkMode?: string;
comments?: Array<{
body: string;
clientStatus?: string;
queueState?: string;
queueTargetRunId?: string | null;
}>;
onAdd?: (body: string) => Promise<void>;
onInterruptQueued?: (runId: string) => Promise<void>;
onStopRun?: (runId: string) => Promise<void>;
stopRunLabel?: string;
stoppingRunLabel?: string;
runFinalizationActions?: readonly {
id: string;
label: string;
onSelect: (runId: string) => Promise<void> | void;
}[];
composerPause?: TaskComposerPause | null;
footer?: ReactNode;
}) => {
mockIssueChatThreadRender(props);
return (
<div data-testid="issue-chat-thread">
Chat thread
{props.onStopRun ? (
<button
type="button"
onClick={() => void props.onStopRun?.("run-active-1")}
>
{props.stopRunLabel ?? "Stop run"}
</button>
) : null}
{props.runFinalizationActions?.map((action) => (
<button
key={action.id}
type="button"
onClick={() => void action.onSelect("run-active-1")}
>
{action.label}
</button>
))}
{props.composerPause ? <TaskChatPausedTakeover {...props.composerPause} /> : null}
{props.footer}
</div>
);
},
}));
// The task chat thread pulls in the MarkdownEditor composer, whose @mdxeditor
// dependency cannot load under jsdom's CSSOM. The stub keeps the suite
// unit-scoped but still renders the threadHeader JSX (the issue header row
// lives inside the thread) so header controls stay testable, records its props
// on the shared thread-render spy, and exposes the same run-control buttons as
// the IssueChatThread stub above.
vi.mock("../components/TaskChatThread", () => ({
TaskChatThread: (props: {
workProducts?: IssueWorkProduct[];
threadHeader?: ReactNode;
onStopRun?: (runId: string) => Promise<void>;
stopRunLabel?: string;
onTryAgainNoLiveExecutionPath?: () => Promise<void> | void;
onRejectInteraction?: (
interaction: RequestConfirmationInteraction,
reason?: string,
) => Promise<void>;
runFinalizationActions?: readonly {
id: string;
label: string;
onSelect: (runId: string) => Promise<void> | void;
}[];
composerPause?: TaskComposerPause | null;
footer?: ReactNode;
}) => {
mockIssueChatThreadRender(props);
return (
<div data-testid="task-chat-thread">
{props.threadHeader}
Task chat thread
{props.workProducts?.map((workProduct) => (
<RichWorkProductCard
key={workProduct.id}
workProduct={workProduct}
href={workProduct.url}
/>
))}
{props.onStopRun ? (
<button
type="button"
onClick={() => void props.onStopRun?.("run-active-1")}
>
{props.stopRunLabel ?? "Stop run"}
</button>
) : null}
{props.onTryAgainNoLiveExecutionPath ? (
<button
type="button"
data-testid="mock-no-live-path-try-again"
onClick={() => void props.onTryAgainNoLiveExecutionPath?.()}
>
Try again
</button>
) : null}
{props.runFinalizationActions?.map((action) => (
<button
key={action.id}
type="button"
onClick={() => void action.onSelect("run-active-1")}
>
{action.label}
</button>
))}
{props.composerPause ? <TaskChatPausedTakeover {...props.composerPause} /> : null}
{props.footer}
</div>
);
},
}));
vi.mock("../components/IssueDocumentsSection", () => ({
IssueDocumentsSection: () => <div>Documents</div>,
}));
vi.mock("../components/MarkdownBody", () => ({
MarkdownBody: ({ children }: { children?: ReactNode }) => (
<div>{children}</div>
),
}));
vi.mock("../components/IssuesList", () => ({
IssuesList: (props: { issueBadgeById?: Map<string, string> }) => {
mockIssuesListRender(props);
return (
<div>
Sub-issues
{Array.from(props.issueBadgeById?.entries() ?? []).map(
([issueId, label]) => (
<span key={issueId}>
{issueId}:{label}
</span>
),
)}
</div>
);
},
}));
vi.mock("../components/IssueProperties", () => ({
IssueProperties: (props: unknown) => {
mockIssuePropertiesRender(props);
return <div>Properties</div>;
},
}));
vi.mock("../components/task-side-panel", () => ({
TaskSidePanel: (props: unknown) => {
mockTaskSidePanelRender(props);
return <div>Task side panel</div>;
},
}));
vi.mock("../components/IssueRunLedger", () => ({
IssueRunLedger: () => <div>Runs</div>,
}));
vi.mock("../components/IssueWorkspaceCard", () => ({
IssueWorkspaceCard: (props: {
onBrowseFiles?: () => void;
onOpenFileByPath?: () => void;
}) => {
mockIssueWorkspaceCardRender(props);
return <div>Workspace</div>;
},
}));
vi.mock("../components/ImageGalleryModal", () => ({
ImageGalleryModal: (props: {
items: IssueAttachment[];
initialIndex: number;
open: boolean;
}) => {
mockImageGalleryRender(props);
return null;
},
}));
vi.mock("../components/ScrollToBottom", () => ({
ScrollToBottom: () => null,
}));
vi.mock("../components/StatusIcon", () => ({
StatusIcon: ({
status,
blockerAttention,
onChange,
}: {
status: string;
blockerAttention?: Issue["blockerAttention"];
onChange?: (status: string) => void;
}) =>
onChange ? (
<button
type="button"
aria-label={`Change status (current: ${status})`}
data-status-icon-state={blockerAttention?.state}
onClick={() => onChange("done")}
>
{status}
</button>
) : (
<span data-status-icon-state={blockerAttention?.state}>{status}</span>
),
}));
vi.mock("../components/PriorityIcon", () => ({
PriorityIcon: ({
priority,
onChange,
}: {
priority: string;
onChange?: (priority: string) => void;
}) =>
onChange ? (
<button
type="button"
aria-label={`Change priority (current: ${priority})`}
onClick={() => onChange("high")}
>
{priority}
</button>
) : (
<span>{priority}</span>
),
}));
vi.mock("../components/ApprovalCard", () => ({
ApprovalCard: () => <div>Approval</div>,
}));
vi.mock("../components/Identity", () => ({
Identity: ({ name, shape }: { name: string; shape?: string }) => (
<span data-shape={shape ?? "circle"}>{name}</span>
),
}));
vi.mock("@/components/ui/button", () => ({
Button: ({
children,
disabled,
onClick,
type = "button",
variant: _variant,
size: _size,
asChild: _asChild,
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: string;
size?: string;
asChild?: boolean;
}) => (
<button {...props} type={type} disabled={disabled} onClick={onClick}>
{children}
</button>
),
}));
vi.mock("@/components/ui/separator", () => ({
Separator: () => <hr />,
}));
vi.mock("@/components/ui/popover", () => ({
Popover: ({ children }: { children?: ReactNode }) => <>{children}</>,
PopoverTrigger: ({ children }: { children?: ReactNode }) => <>{children}</>,
PopoverContent: ({ children }: { children?: ReactNode }) => (
<div>{children}</div>
),
}));
vi.mock("@/components/ui/dialog", () => ({
Dialog: ({ children, open }: { children?: ReactNode; open?: boolean }) =>
open ? <div>{children}</div> : null,
DialogContent: ({
children,
className,
}: {
children?: ReactNode;
className?: string;
}) => (
<div data-slot="dialog-content" className={className}>
{children}
</div>
),
DialogDescription: ({
children,
className,
}: {
children?: ReactNode;
className?: string;
}) => <p className={className}>{children}</p>,
DialogFooter: ({
children,
className,
}: {
children?: ReactNode;
className?: string;
}) => <div className={className}>{children}</div>,
DialogHeader: ({
children,
className,
}: {
children?: ReactNode;
className?: string;
}) => <div className={className}>{children}</div>,
DialogTitle: ({
children,
className,
}: {
children?: ReactNode;
className?: string;
}) => <h2 className={className}>{children}</h2>,
}));
vi.mock("@/components/ui/sheet", () => ({
Sheet: ({ children, open }: { children?: ReactNode; open?: boolean }) =>
open ? <div>{children}</div> : null,
SheetContent: ({
children,
className,
"data-testid": testId,
}: {
children?: ReactNode;
className?: string;
"data-testid"?: string;
}) => (
<div data-slot="sheet-content" className={className} data-testid={testId}>
{children}
<button type="button" data-slot="sheet-close">
Close
</button>
</div>
),
SheetHeader: ({ children }: { children?: ReactNode }) => (
<div>{children}</div>
),
SheetDescription: ({ children }: { children?: ReactNode }) => (
<p>{children}</p>
),
SheetTitle: ({ children }: { children?: ReactNode }) => <h2>{children}</h2>,
}));
vi.mock("@/components/ui/scroll-area", () => ({
ScrollArea: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
}));
vi.mock("@/components/ui/skeleton", () => ({
Skeleton: () => <div data-testid="skeleton" />,
}));
vi.mock("@/components/ui/tabs", () => ({
Tabs: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
TabsContent: ({
children,
className,
"data-testid": testId,
}: {
children?: ReactNode;
className?: string;
"data-testid"?: string;
}) => (
<div className={className} data-testid={testId}>
{children}
</div>
),
TabsList: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
TabsTrigger: ({ children }: { children?: ReactNode }) => (
<button type="button">{children}</button>
),
}));
vi.mock("@/components/ui/textarea", () => ({
Textarea: (props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) => (
<textarea {...props} />
),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = undefined;
flushSync(() => {
result = callback();
});
await result;
}
function createTooltipRoot(container: Element): Root {
const root = createRoot(container);
return {
render(children) {
root.render(<TooltipProvider>{children}</TooltipProvider>);
},
unmount() {
root.unmount();
},
};
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((innerResolve) => {
resolve = innerResolve;
});
return { promise, resolve };
}
function createIssue(overrides: Partial<Issue> = {}): Issue {
return {
id: "issue-1",
companyId: "company-1",
projectId: null,
projectWorkspaceId: null,
goalId: "goal-1",
parentId: null,
title: "Issue detail smoke",
description: "Loads after the initial pending query.",
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
executionWorkspaceSettings: null,
currentExecutionWorkspace: null,
createdByAgentId: null,
createdByUserId: null,
identifier: "PAP-1",
issueNumber: 1,
originKind: "manual",
originId: null,
originRunId: null,
originFingerprint: "default",
requestDepth: 0,
billingCode: null,
assigneeAdapterOverrides: null,
executionPolicy: null,
executionState: null,
startedAt: null,
completedAt: null,
cancelledAt: null,
hiddenAt: null,
createdAt: new Date("2026-04-21T00:00:00.000Z"),
updatedAt: new Date("2026-04-21T00:00:00.000Z"),
labels: [],
labelIds: [],
ancestors: [],
documentSummaries: [],
...overrides,
} as Issue;
}
function createIssueComment(
overrides: Partial<IssueComment> = {},
): IssueComment {
return {
id: "comment-1",
companyId: "company-1",
issueId: "issue-1",
authorType: "user",
authorAgentId: null,
authorUserId: "user-1",
body: "Fresh comment",
presentation: null,
metadata: null,
createdAt: new Date("2026-04-21T00:00:05.000Z"),
updatedAt: new Date("2026-04-21T00:00:05.000Z"),
...overrides,
};
}
function createQueuedCommentQueue(
overrides: Partial<IssueQueuedCommentQueue> = {},
): IssueQueuedCommentQueue {
const comment = createIssueComment({
id: "queued-comment-1",
body: "Queued through the handoff",
});
return {
issueId: "issue-1",
queueId: "wake-queue-1",
state: "deferred",
targetRunId: "run-active-1",
revision: "queue-revision-1",
protocol: "paperclip_runner_v1",
steeringDisposition: "available",
entries: [{ comment, position: 0, canEdit: true, canDiscard: true }],
...overrides,
};
}
function createAttachment(
overrides: Partial<IssueAttachment> & { id: string },
): IssueAttachment {
const { id, ...attachmentOverrides } = overrides;
return {
id,
companyId: "company-1",
issueId: "issue-1",
issueCommentId: null,
assetId: `asset-${id}`,
provider: "local_disk",
objectKey: `attachments/${id}`,
contentType: overrides.contentType ?? "application/octet-stream",
byteSize: overrides.byteSize ?? 4096,
sha256: "sha256",
originalFilename: overrides.originalFilename ?? null,
createdByAgentId: null,
createdByUserId: null,
createdAt: new Date("2026-04-21T00:00:00.000Z"),
updatedAt: new Date("2026-04-21T00:00:00.000Z"),
contentPath: overrides.contentPath ?? `/api/attachments/${id}/content`,
openPath: overrides.openPath ?? `/api/attachments/${id}/content`,
downloadPath:
overrides.downloadPath ?? `/api/attachments/${id}/content?download=1`,
...attachmentOverrides,
};
}
function createArtifactWorkProduct(
overrides: Partial<IssueWorkProduct> & {
id: string;
attachmentId: string;
contentType: string;
originalFilename: string;
},
): IssueWorkProduct {
const {
id,
attachmentId,
contentType,
originalFilename,
...workProductOverrides
} = overrides;
const contentPath = `/api/attachments/${attachmentId}/content`;
return {
id,
companyId: "company-1",
projectId: null,
issueId: "issue-1",
executionWorkspaceId: null,
runtimeServiceId: null,
type: "artifact",
provider: "paperclip",
externalId: null,
title: overrides.title ?? originalFilename,
url: null,
status: "active",
reviewState: "none",
isPrimary: false,
healthStatus: "unknown",
summary: null,
metadata: {
attachmentId,
contentType,
byteSize: 4096,
contentPath,
openPath: contentPath,
downloadPath: `${contentPath}?download=1`,
originalFilename,
},
createdByRunId: null,
createdAt: new Date("2026-04-21T00:00:00.000Z"),
updatedAt: new Date("2026-04-21T00:00:00.000Z"),
...workProductOverrides,
} as IssueWorkProduct;
}
function createAgent(overrides: Partial<Agent> = {}): Agent {
return {
id: "agent-1",
companyId: "company-1",
name: "CodexCoder",
urlKey: "codexcoder",
role: "engineer",
title: "Software Engineer",
icon: "code",
status: "active",
reportsTo: null,
capabilities: null,
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
pauseReason: null,
pausedAt: null,
permissions: { canCreateAgents: false },
lastHeartbeatAt: null,
metadata: null,
createdAt: new Date("2026-04-21T00:00:00.000Z"),
updatedAt: new Date("2026-04-21T00:00:00.000Z"),
...overrides,
};
}
function createPauseHold(
overrides: Partial<IssueTreeHold> = {},
): IssueTreeHold {
const now = new Date("2026-04-21T00:00:00.000Z");
return {
id: "hold-1",
companyId: "company-1",
rootIssueId: "issue-1",
mode: "pause",
status: "active",
reason: null,
releasePolicy: { strategy: "manual", note: "full_pause" },
createdByActorType: "user",
createdByAgentId: null,
createdByUserId: "user-1",
createdByRunId: null,
releasedAt: null,
releasedByActorType: null,
releasedByAgentId: null,
releasedByUserId: null,
releasedByRunId: null,
releaseReason: null,
releaseMetadata: null,
createdAt: now,
updatedAt: now,
members: [
{
id: "hold-member-root",
companyId: "company-1",
holdId: "hold-1",
issueId: "issue-1",
parentIssueId: null,
depth: 0,
issueIdentifier: "PAP-1",
issueTitle: "Issue detail smoke",
issueStatus: "todo",
assigneeAgentId: null,
assigneeUserId: null,
activeRunId: null,
activeRunStatus: null,
skipped: false,
skipReason: null,
createdAt: now,
},
{
id: "hold-member-child",
companyId: "company-1",
holdId: "hold-1",
issueId: "child-1",
parentIssueId: "issue-1",
depth: 1,
issueIdentifier: "PAP-2",
issueTitle: "Held child",
issueStatus: "todo",
assigneeAgentId: null,
assigneeUserId: null,
activeRunId: null,
activeRunStatus: null,
skipped: false,
skipReason: null,
createdAt: now,
},
],
...overrides,
};
}
function createResumePreview(): IssueTreeControlPreview {
return {
companyId: "company-1",
rootIssueId: "issue-1",
mode: "resume",
generatedAt: new Date("2026-04-21T00:00:00.000Z"),
releasePolicy: { strategy: "manual" },
totals: {
totalIssues: 2,
affectedIssues: 2,
skippedIssues: 0,
activeRuns: 0,
queuedRuns: 0,
affectedAgents: 1,
},
countsByStatus: { todo: 2 },
issues: [
{
id: "issue-1",
identifier: "PAP-1",
title: "Issue detail smoke",
status: "todo",
parentId: null,
depth: 0,
assigneeAgentId: "agent-1",
assigneeUserId: null,
activeRun: null,
activeHoldIds: ["hold-1"],
action: "resume",
skipped: false,
skipReason: null,
},
{
id: "child-1",
identifier: "PAP-2",
title: "Held child",
status: "todo",
parentId: "issue-1",
depth: 1,
assigneeAgentId: "agent-1",
assigneeUserId: null,
activeRun: null,
activeHoldIds: ["hold-1"],
action: "resume",
skipped: false,
skipReason: null,
},
],
skippedIssues: [],
activeRuns: [],
affectedAgents: [{ agentId: "agent-1", issueCount: 2, activeRunCount: 0 }],
warnings: [],
};
}
function createPausePreview(): IssueTreeControlPreview {
return {
companyId: "company-1",
rootIssueId: "issue-1",
mode: "pause",
generatedAt: new Date("2026-04-21T00:00:00.000Z"),
releasePolicy: { strategy: "manual" },
totals: {
totalIssues: 3,
affectedIssues: 2,
skippedIssues: 1,
activeRuns: 1,
queuedRuns: 0,
affectedAgents: 0,
},
countsByStatus: { todo: 2 },
issues: [
{
id: "issue-1",
identifier: "PAP-1",
title: "Issue detail smoke",
status: "todo",
parentId: null,
depth: 0,
assigneeAgentId: null,
assigneeUserId: null,
activeRun: null,
activeHoldIds: [],
action: "pause",
skipped: false,
skipReason: null,
},
{
id: "child-1",
identifier: "PAP-2",
title: "Paused child",
status: "in_review",
parentId: "issue-1",
depth: 1,
assigneeAgentId: null,
assigneeUserId: null,
activeRun: null,
activeHoldIds: [],
action: "pause",
skipped: false,
skipReason: null,
},
{
id: "child-2",
identifier: "PAP-3",
title: "Completed child",
status: "done",
parentId: "issue-1",
depth: 1,
assigneeAgentId: null,
assigneeUserId: null,
activeRun: null,
activeHoldIds: [],
action: "pause",
skipped: true,
skipReason: "terminal_status",
},
],
skippedIssues: [
{
id: "child-2",
identifier: "PAP-3",
title: "Completed child",
status: "done",
parentId: "issue-1",
depth: 1,
assigneeAgentId: null,
assigneeUserId: null,
activeRun: null,
activeHoldIds: [],
action: "pause",
skipped: true,
skipReason: "terminal_status",
},
],
activeRuns: [],
affectedAgents: [],
warnings: [],
};
}
function createRestorePreview(): IssueTreeControlPreview {
return {
companyId: "company-1",
rootIssueId: "issue-1",
mode: "restore",
generatedAt: new Date("2026-04-21T00:00:00.000Z"),
releasePolicy: { strategy: "manual" },
totals: {
totalIssues: 2,
affectedIssues: 1,
skippedIssues: 1,
activeRuns: 0,
queuedRuns: 0,
affectedAgents: 1,
},
countsByStatus: { todo: 1, cancelled: 1 },
issues: [
{
id: "issue-1",
identifier: "PAP-1",
title: "Issue detail smoke",
status: "todo",
parentId: null,
depth: 0,
assigneeAgentId: null,
assigneeUserId: null,
activeRun: null,
activeHoldIds: [],
action: "restore",
skipped: true,
skipReason: "not_cancelled",
},
{
id: "child-1",
identifier: "PAP-2",
title: "Cancelled child",
status: "cancelled",
parentId: "issue-1",
depth: 1,
assigneeAgentId: "agent-1",
assigneeUserId: null,
activeRun: null,
activeHoldIds: ["cancel-hold-1"],
action: "restore",
skipped: false,
skipReason: null,
},
],
skippedIssues: [
{
id: "issue-1",
identifier: "PAP-1",
title: "Issue detail smoke",
status: "todo",
parentId: null,
depth: 0,
assigneeAgentId: null,
assigneeUserId: null,
activeRun: null,
activeHoldIds: [],
action: "restore",
skipped: true,
skipReason: "not_cancelled",
},
],
activeRuns: [],
affectedAgents: [{ agentId: "agent-1", issueCount: 1, activeRunCount: 0 }],
warnings: [],
};
}
function createCancelPreview(issueCount = 8): IssueTreeControlPreview {
const issues = Array.from({ length: issueCount }, (_, index) => ({
id: index === 0 ? "issue-1" : `child-${index}`,
identifier: index === 0 ? "PAP-1" : `PAP-${index + 1}`,
title: index === 0 ? "Issue detail smoke" : `Cancellable child ${index}`,
status: "todo" as const,
parentId: index === 0 ? null : "issue-1",
depth: index === 0 ? 0 : 1,
assigneeAgentId: null,
assigneeUserId: null,
activeRun: null,
activeHoldIds: [],
action: "cancel" as const,
skipped: false,
skipReason: null,
}));
return {
companyId: "company-1",
rootIssueId: "issue-1",
mode: "cancel",
generatedAt: new Date("2026-04-21T00:00:00.000Z"),
releasePolicy: { strategy: "manual" },
totals: {
totalIssues: issueCount,
affectedIssues: issueCount,
skippedIssues: 0,
activeRuns: 0,
queuedRuns: 0,
affectedAgents: 0,
},
countsByStatus: { todo: issueCount },
issues,
skippedIssues: [],
activeRuns: [],
affectedAgents: [],
warnings: [],
};
}
async function flushReact() {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
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 flushReact();
}
}
throw lastError;
}
describe("IssueDetail", () => {
let container: HTMLDivElement;
let root: Root;
let queryClient: QueryClient;
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
mockPanelState.panelVisible = true;
mockSidebarState.isMobile = false;
container = document.createElement("div");
document.body.appendChild(container);
root = createTooltipRoot(container);
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
vi.spyOn(window, "scrollTo").mockImplementation(() => {});
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
text: async () => "# Attachment preview",
} as Response);
mockIssuesApi.list.mockResolvedValue([]);
mockIssuesApi.listAll.mockImplementation((...args) => mockIssuesApi.list(...args));
mockIssuesApi.listComments.mockResolvedValue([]);
mockIssuesApi.listAttachments.mockResolvedValue([]);
mockIssuesApi.listWorkProducts.mockResolvedValue([]);
mockIssuesApi.listFeedbackVotes.mockResolvedValue([]);
mockIssuesApi.listInteractions.mockResolvedValue([]);
mockIssuesApi.getQueuedComments.mockResolvedValue(
createQueuedCommentQueue({
queueId: null,
state: null,
targetRunId: null,
entries: [],
}),
);
mockIssuesApi.interruptQueuedComments.mockReset().mockResolvedValue(createQueuedCommentQueue());
mockIssuesApi.editQueuedComment.mockResolvedValue(
createQueuedCommentQueue(),
);
mockIssuesApi.reorderQueuedComments.mockResolvedValue(
createQueuedCommentQueue(),
);
mockIssuesApi.steerQueuedComment.mockResolvedValue(
createQueuedCommentQueue(),
);
mockIssuesApi.discardQueuedComment.mockResolvedValue(
createQueuedCommentQueue({ entries: [] }),
);
mockIssuesApi.markRead.mockResolvedValue({
id: "issue-1",
lastReadAt: new Date().toISOString(),
});
mockIssuesApi.archiveFromInbox.mockResolvedValue({
id: "issue-1",
archivedAt: new Date(),
});
mockIssuesApi.unarchiveFromInbox.mockResolvedValue({ ok: true });
mockIssuesApi.getTreeControlState.mockResolvedValue({
activePauseHold: null,
});
mockIssuesApi.listTreeHolds.mockResolvedValue([]);
mockActivityApi.forIssue.mockResolvedValue([]);
mockActivityApi.runsForIssue.mockResolvedValue([]);
mockHeartbeatsApi.liveRunsForIssue.mockResolvedValue([]);
mockHeartbeatsApi.activeRunForIssue.mockResolvedValue(null);
mockAgentsApi.list.mockResolvedValue([]);
mockAccessApi.getCurrentBoardAccess.mockResolvedValue({
companyIds: ["company-1"],
isInstanceAdmin: true,
source: "session",
keyId: null,
user: null,
userId: null,
});
mockAccessApi.listUserDirectory.mockResolvedValue({ users: [] });
mockAuthApi.getSession.mockResolvedValue({ session: null, user: null });
mockProjectsApi.list.mockResolvedValue([]);
mockDecisionsApi.list.mockResolvedValue([]);
mockInstanceSettingsApi.getGeneral.mockResolvedValue({
keyboardShortcuts: false,
feedbackDataSharingPreference: "prompt",
});
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIssuePlanDecompositions: false,
enableExperimentalFileViewer: false,
enableExternalObjects: false,
enableStreamlinedUi: true,
});
mockIssuesApi.listAcceptedPlanDecompositions.mockResolvedValue([]);
mockIssuesApi.getDocument.mockResolvedValue(null);
mockOpenPanel.mockClear();
mockClosePanel.mockClear();
mockSetPanelVisible.mockClear();
mockRequestPanelMaximize.mockClear();
mockClearPanelMaximizeRequest.mockClear();
mockSetBreadcrumbPanelControl.mockClear();
mockSetMobileToolbar.mockClear();
mockIssuePropertiesRender.mockClear();
mockTaskSidePanelRender.mockClear();
mockIssuesListRender.mockClear();
mockIssueChatThreadRender.mockClear();
mockImageGalleryRender.mockClear();
mockIssueWorkspaceCardRender.mockClear();
mockNavigate.mockClear();
mockOpenNewIssue.mockClear();
mockOpenNewProject.mockClear();
mockOpenNewGoal.mockClear();
mockPushToast.mockClear();
mockLocation.pathname = "/issues/PAP-1";
mockLocation.search = "";
mockLocation.hash = "";
mockLocation.state = null;
mockRouteParams.issueId = "PAP-1";
mockRouteParams.companyPrefix = "PAP";
});
afterEach(async () => {
await act(async () => {
root.unmount();
});
queryClient.clear();
container.remove();
document.body.innerHTML = "";
localStorage.clear();
sessionStorage.clear();
vi.restoreAllMocks();
});
it("opens artifact cards in the shared gallery at the selected image without duplicating attachments", async () => {
mockIssuesApi.get.mockResolvedValue(createIssue());
mockIssuesApi.listAttachments.mockResolvedValue([
createAttachment({
id: "chat-image",
contentType: "image/png",
originalFilename: "chat.png",
}),
createAttachment({
id: "00000000-0000-4000-8000-000000000001",
contentType: "image/png",
originalFilename: "artifact.png",
}),
]);
mockIssuesApi.listWorkProducts.mockResolvedValue([
createArtifactWorkProduct({
id: "artifact-1",
attachmentId: "00000000-0000-4000-8000-000000000001",
contentType: "image/png",
originalFilename: "artifact.png",
}),
createArtifactWorkProduct({
id: "artifact-2",
attachmentId: "00000000-0000-4000-8000-000000000002",
contentType: "image/png",
originalFilename: "output.png",
}),
]);
const windowOpen = vi.spyOn(window, "open").mockImplementation(() => null);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await waitForAssertion(() => {
expect(
container.querySelector(
'button[aria-label="Open gallery: output.png"]',
),
).not.toBeNull();
});
for (const [filename, index] of [
["artifact.png", 1],
["output.png", 2],
] as const) {
await act(async () => {
(
container.querySelector(
`button[aria-label="Open gallery: ${filename}"]`,
) as HTMLButtonElement
).click();
});
expect(mockImageGalleryRender.mock.calls.at(-1)?.[0]).toMatchObject({
open: true,
initialIndex: index,
items: [
{ id: "chat-image" },
{ id: "00000000-0000-4000-8000-000000000001" },
{
id: "work-product-artifact-2",
downloadPath:
"/api/attachments/00000000-0000-4000-8000-000000000002/content?download=1",
},
],
});
}
expect(windowOpen).not.toHaveBeenCalled();
});
it("loads from the pending state into issue detail without changing hook order", async () => {
const issueRequest = createDeferred<Issue>();
mockIssuesApi.get.mockReturnValueOnce(issueRequest.promise);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
issueRequest.resolve(createIssue());
await flushReact();
await flushReact();
expect(container.textContent).toContain("Issue detail smoke");
expect(container.textContent).toContain("Task chat thread");
const titleGroup = container.querySelector(
'[data-slot="task-detail-title"]',
);
const identifier = titleGroup?.querySelector(
'[data-slot="task-title-identifier"]',
);
const titleRow = titleGroup?.parentElement;
const titleActions = container.querySelector(
'[data-slot="task-title-actions"]',
);
expect(titleGroup?.textContent?.replace(/\s+/g, " ").trim()).toBe(
"Issue detail smokePAP-1",
);
expect(identifier?.textContent).toBe("PAP-1");
expect(titleRow?.className).toContain("pr-8");
expect(titleActions?.className).toContain("absolute");
expect(titleActions?.className).toContain("top-0");
expect(
titleActions?.querySelector('button[aria-label="More task actions"]'),
).not.toBeNull();
expect(
consoleErrorSpy.mock.calls.some((call: unknown[]) =>
String(call[0]).includes(
"React has detected a change in the order of Hooks",
),
),
).toBe(false);
});
it.each([false, true])(
"preserves explicit upload receipt IDs through the page mutation (reassign=%s)",
async (reassign) => {
const issue = createIssue();
const id = "9af8228f-0be7-45ae-a104-6fbe0af6f1d3";
const file = new File(["fresh"], "fresh.txt", { type: "text/plain" });
mockIssuesApi.get.mockResolvedValue(issue);
mockIssuesApi.uploadAttachment.mockResolvedValue({
id,
issueId: issue.id,
companyId: issue.companyId,
contentPath: `/api/attachments/${id}/content`,
});
mockIssuesApi.addComment
.mockClear()
.mockResolvedValue(createIssueComment());
mockIssuesApi.update
.mockClear()
.mockResolvedValue({ ...issue, comment: createIssueComment() });
await act(async () =>
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
),
);
await waitForAssertion(() =>
expect(mockIssueChatThreadRender).toHaveBeenCalled(),
);
const props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as {
onAttachImage(file: File): Promise<{ id: string }>;
onAdd(
body: string,
reopen?: boolean,
reassignment?: {
assigneeAgentId: string | null;
assigneeUserId: string | null;
},
attachmentIds?: string[],
): Promise<void>;
};
let uploaded!: { id: string };
await act(async () => {
uploaded = await props.onAttachImage(file);
});
expect(uploaded.id).toBe(id);
const invalidate = vi.spyOn(queryClient, "invalidateQueries");
await act(async () =>
props.onAdd(
"Inspect the new file",
undefined,
reassign
? { assigneeAgentId: "agent-2", assigneeUserId: null }
: undefined,
[uploaded.id],
),
);
for (const ref of [issue.id, issue.identifier]) {
expect(invalidate).toHaveBeenCalledWith({
queryKey: queryKeys.issues.attachments(ref!),
});
}
if (reassign) {
expect(mockIssuesApi.update).toHaveBeenCalledWith(issue.identifier, {
comment: "Inspect the new file",
assigneeAgentId: "agent-2",
assigneeUserId: null,
attachmentIds: [id],
});
expect(mockIssuesApi.addComment).not.toHaveBeenCalled();
} else {
expect(mockIssuesApi.addComment).toHaveBeenCalledWith(
issue.identifier,
"Inspect the new file",
undefined,
undefined,
[id],
);
expect(mockIssuesApi.update).not.toHaveBeenCalled();
}
},
);
it("keeps hierarchy breadcrumbs and label chips out of the Streamlined task header", async () => {
mockIssuesApi.get.mockResolvedValue(
createIssue({
ancestors: [
{
id: "parent-1",
identifier: "PAP-0",
title: "Parent task visible in Properties",
},
] as Issue["ancestors"],
labels: [
{
id: "label-1",
companyId: "company-1",
name: "Quick win",
color: "#22c55e",
createdAt: new Date("2026-04-21T00:00:00.000Z"),
updatedAt: new Date("2026-04-21T00:00:00.000Z"),
},
],
labelIds: ["label-1"],
}),
);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
expect(container.textContent).not.toContain(
"Parent task visible in Properties",
);
expect(container.textContent).not.toContain("Quick win");
expect(container.textContent).toContain("Issue detail smoke");
});
it("preserves hierarchy breadcrumbs and label chips when Streamlined UI is off", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIssuePlanDecompositions: false,
enableExperimentalFileViewer: false,
enableExternalObjects: false,
enableStreamlinedUi: false,
});
mockIssuesApi.get.mockResolvedValue(
createIssue({
ancestors: [
{
id: "parent-1",
identifier: "PAP-0",
title: "Parent task breadcrumb",
},
] as Issue["ancestors"],
labels: [
{
id: "label-1",
companyId: "company-1",
name: "Legacy label",
color: "#22c55e",
createdAt: new Date("2026-04-21T00:00:00.000Z"),
updatedAt: new Date("2026-04-21T00:00:00.000Z"),
},
],
labelIds: ["label-1"],
}),
);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
expect(container.textContent).toContain("Parent task breadcrumb");
expect(container.textContent).toContain("Legacy label");
});
it("lifts the redesigned desktop thread into the side-panel header band", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIssuePlanDecompositions: false,
enableExperimentalFileViewer: false,
enableExternalObjects: false,
enableStreamlinedUi: false,
});
mockIssuesApi.get.mockResolvedValue(createIssue());
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await waitForAssertion(() => {
const content = container.querySelector(
'[data-testid="issue-detail-content"]',
);
expect(content).not.toBeNull();
expect(content!.className).toContain("-mt-4");
expect(content!.className).toContain("md:-mt-6");
});
});
it("does not register a redundant breadcrumb side-panel toggle in Streamlined UI", async () => {
mockIssuesApi.get.mockResolvedValue(createIssue());
mockPanelState.panelVisible = false;
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
expect(
mockSetBreadcrumbToolbar.mock.calls.every(([node]) => node === null),
).toBe(true);
});
it("retains the production breadcrumb side-panel toggle when Streamlined UI is off", async () => {
mockIssuesApi.get.mockResolvedValue(createIssue());
mockPanelState.panelVisible = false;
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIssuePlanDecompositions: false,
enableExperimentalFileViewer: false,
enableExternalObjects: false,
enableStreamlinedUi: false,
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
const toolbar = mockSetBreadcrumbToolbar.mock.calls
.map(([node]) => node)
.find((node) => node !== null) as ReactElement<{
children: ReactElement<{ onToggle: () => void }>;
}>;
expect(toolbar).toBeDefined();
toolbar.props.children.props.onToggle();
expect(mockSetPanelVisible).toHaveBeenCalledWith(true);
});
it("uses task activity for recency and ignores passive revisits", async () => {
const authRequest = createDeferred<{
session: { userId: string };
user: { id: string };
}>();
const issue = createIssue({
title: "Initial recent title",
status: "todo",
updatedAt: new Date(100),
});
mockAuthApi.getSession.mockReturnValue(authRequest.promise);
mockIssuesApi.get.mockResolvedValue(issue);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
expect(
readRecentTasks(getRecentTasksStorageKey("company-1", null), "company-1"),
).toEqual([]);
await act(async () => {
authRequest.resolve({
session: { userId: "user-1" },
user: { id: "user-1" },
});
});
await waitForAssertion(() => {
expect(
readRecentTasks(
getRecentTasksStorageKey("company-1", "user-1"),
"company-1",
),
).toEqual([
expect.objectContaining({
id: "issue-1",
title: "Initial recent title",
status: "todo",
recordedAt: 100,
}),
]);
});
act(() => {
queryClient.setQueryData(queryKeys.issues.detail("PAP-1"), {
...issue,
title: "Snapshot refresh title",
status: "in_progress",
updatedAt: new Date(200),
});
});
await flushReact();
expect(
readRecentTasks(
getRecentTasksStorageKey("company-1", "user-1"),
"company-1",
)[0],
).toMatchObject({
title: "Snapshot refresh title",
status: "in_progress",
recordedAt: 200,
});
});
it("opens a closed desktop pane and routes an ordinary document to its own tab on direct load", async () => {
mockPanelState.panelVisible = false;
mockLocation.hash = "#document-qa-evidence";
mockIssuesApi.get.mockResolvedValue(createIssue());
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await waitForAssertion(() => {
expect(mockSetPanelVisible).toHaveBeenCalledWith(true);
const panel = mockOpenPanel.mock.calls.at(-1)?.[0]?.props.children as
{ props?: Record<string, unknown> } | undefined;
expect(panel?.props?.documentDeepLink).toMatchObject({
tab: "document",
documentKey: "qa-evidence",
});
});
});
it("leaves ordinary document links to the classic center-column surface", async () => {
mockPanelState.panelVisible = false;
mockLocation.hash = "#document-qa-evidence";
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIssuePlanDecompositions: false,
enableExperimentalFileViewer: false,
enableExternalObjects: false,
enableClassicTaskInterface: true,
});
mockIssuesApi.get.mockResolvedValue(createIssue());
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await waitForAssertion(() => {
expect(
container.querySelector('[data-testid="issue-chat-thread"]'),
).not.toBeNull();
expect(mockSetPanelVisible).not.toHaveBeenCalled();
const panel = mockOpenPanel.mock.calls.at(-1)?.[0]?.props.children as
{ props?: Record<string, unknown> } | undefined;
expect(panel?.props?.documentDeepLink).toBeNull();
});
});
it("clears document routing when the URL no longer names a document", async () => {
mockLocation.hash = "#document-qa-evidence";
mockIssuesApi.get.mockResolvedValue(createIssue());
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await waitForAssertion(() => {
const panel = mockOpenPanel.mock.calls.at(-1)?.[0]?.props.children as
{ props?: Record<string, unknown> } | undefined;
expect(panel?.props?.documentDeepLink).toMatchObject({
documentKey: "qa-evidence",
});
});
mockLocation.hash = "#work-product-1";
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await waitForAssertion(() => {
const panel = mockOpenPanel.mock.calls.at(-1)?.[0]?.props.children as
{ props?: Record<string, unknown> } | undefined;
expect(panel?.props?.documentDeepLink).toBeNull();
});
});
it("routes plan to the Plan pane tab and leaves continuation-summary on its existing surface", async () => {
mockPanelState.panelVisible = false;
mockLocation.hash = "#document-plan";
mockIssuesApi.get.mockResolvedValue(createIssue());
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await waitForAssertion(() => {
const panel = mockOpenPanel.mock.calls.at(-1)?.[0]?.props.children as
{ props?: Record<string, unknown> } | undefined;
expect(panel?.props?.documentDeepLink).toMatchObject({
tab: "plans",
documentKey: "plan",
});
});
mockSetPanelVisible.mockClear();
mockLocation.hash = "#document-continuation-summary";
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
expect(mockSetPanelVisible).not.toHaveBeenCalled();
await waitForAssertion(() => {
const panel = mockOpenPanel.mock.calls.at(-1)?.[0]?.props.children as
{ props?: Record<string, unknown> } | undefined;
expect(panel?.props?.documentDeepLink).toBeNull();
});
});
it("replays document routing when the current same-page hash is clicked again", async () => {
mockLocation.hash = "#document-qa-evidence";
mockIssuesApi.get.mockResolvedValue(createIssue());
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await waitForAssertion(() => {
const panel = mockOpenPanel.mock.calls.at(-1)?.[0]?.props.children as
{ props?: Record<string, unknown> } | undefined;
expect(
(panel?.props?.documentDeepLink as { requestId?: number } | null)
?.requestId,
).toBe(1);
});
const link = document.createElement("a");
link.href = "#document-qa-evidence";
link.textContent = "QA evidence";
container.appendChild(link);
await act(async () => link.click());
await waitForAssertion(() => {
const panel = mockOpenPanel.mock.calls.at(-1)?.[0]?.props.children as
{ props?: Record<string, unknown> } | undefined;
expect(
(panel?.props?.documentDeepLink as { requestId?: number } | null)
?.requestId,
).toBe(2);
});
});
it("maximizes the desktop pane once per viewer=full deep link", async () => {
mockPanelState.panelVisible = false;
mockLocation.hash = "#document-qa-evidence&viewer=full";
mockIssuesApi.get.mockResolvedValue(createIssue());
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await waitForAssertion(() => {
expect(mockSetPanelVisible).toHaveBeenCalledWith(true);
expect(mockRequestPanelMaximize).toHaveBeenCalledTimes(1);
});
// Replaying the same hash (same-page link click) reopens the document but
// must not re-maximize a pane the user may have deliberately restored.
const link = document.createElement("a");
link.href = "#document-qa-evidence&viewer=full";
link.textContent = "QA evidence";
container.appendChild(link);
await act(async () => link.click());
expect(mockRequestPanelMaximize).toHaveBeenCalledTimes(1);
// Ending the deep link drops the pending request and re-arms the guard.
mockLocation.hash = "";
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await waitForAssertion(() => {
expect(mockClearPanelMaximizeRequest).toHaveBeenCalled();
});
});
it("re-maximizes when navigating to another issue with an identical viewer=full hash", async () => {
mockPanelState.panelVisible = false;
mockLocation.hash = "#document-qa-evidence&viewer=full";
mockIssuesApi.get.mockResolvedValue(createIssue());
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await waitForAssertion(() => {
expect(mockRequestPanelMaximize).toHaveBeenCalledTimes(1);
});
// Navigate to a sibling issue whose URL carries the same document hash.
// IssueDetail stays mounted; the destination pane must still maximize.
mockRouteParams.issueId = "PAP-2";
mockLocation.pathname = "/issues/PAP-2";
mockIssuesApi.get.mockResolvedValue(
createIssue({ id: "issue-2", identifier: "PAP-2" }),
);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await waitForAssertion(() => {
expect(mockRequestPanelMaximize).toHaveBeenCalledTimes(2);
});
});
it("opens the mobile properties sheet for a document deep link", async () => {
mockSidebarState.isMobile = true;
mockLocation.hash = "#document-qa-evidence";
mockIssuesApi.get.mockResolvedValue(createIssue());
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await waitForAssertion(() => {
expect(mockTaskSidePanelRender).toHaveBeenCalledWith(
expect.objectContaining({
inline: true,
documentDeepLink: expect.objectContaining({
tab: "document",
documentKey: "qa-evidence",
}),
}),
);
});
expect(mockSetPanelVisible).not.toHaveBeenCalled();
});
it("opens a plan deep link in the shared mobile side-panel sheet", async () => {
mockSidebarState.isMobile = true;
mockLocation.hash = "#document-plan";
mockIssuesApi.get.mockResolvedValue(createIssue());
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await waitForAssertion(() => {
expect(mockTaskSidePanelRender).toHaveBeenCalledWith(
expect.objectContaining({
inline: true,
documentDeepLink: expect.objectContaining({
tab: "plans",
documentKey: "plan",
}),
}),
);
});
const panel = document.querySelector(
'[data-testid="mobile-task-side-panel"]',
);
expect(panel).not.toBeNull();
expect(panel?.className).toContain("max-h-(--sz-85dvh)");
expect(panel?.className).toContain("w-full");
expect(panel?.className).toContain("max-w-none");
expect(panel?.textContent).toContain("Task side panel");
expect(panel?.querySelector('[data-slot="sheet-close"]')).not.toBeNull();
});
it("loads subtask membership and created work independently and refreshes on issue activity", async () => {
const source = createIssue();
const child = createIssue({ id: "manual-child", parentId: source.id, title: "Manual child" });
const created = createIssue({ id: "created-task", parentId: null, title: "Created elsewhere" });
mockIssuesApi.get.mockResolvedValue(source);
mockIssuesApi.list.mockImplementation((_companyId, filters?: { descendantOf?: string; createdFromIssueId?: string }) =>
Promise.resolve(filters?.descendantOf === source.id ? [child] : filters?.createdFromIssueId === source.id ? [created] : []),
);
await act(async () => { root.render(<QueryClientProvider client={queryClient}><IssueDetail /></QueryClientProvider>); });
await flushReact();
await flushReact();
const taskProjection = () => mockOpenPanel.mock.calls.at(-1)?.[0]?.props.children?.props.tasksTab;
expect(taskProjection()?.content.props.subtasks.map((row: Issue) => row.id)).toEqual([child.id]);
expect(taskProjection()?.content.props.createdTasks.map((row: Issue) => row.id)).toEqual([created.id]);
expect(taskProjection()?.count).toBe(2);
const next = createIssue({ id: "new-created-task", parentId: source.id });
mockIssuesApi.list.mockImplementation((_companyId, filters?: { descendantOf?: string; createdFromIssueId?: string }) =>
Promise.resolve(filters?.descendantOf === source.id ? [child, next] : filters?.createdFromIssueId === source.id ? [created, next] : []),
);
await act(async () => { await queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(source.companyId) }); });
await flushReact();
expect(taskProjection()?.count).toBe(3);
expect(taskProjection()?.content.props.createdTasks.map((row: Issue) => row.id)).toContain(next.id);
});
it("moves subtask data into the properties panel instead of the chat center pane", async () => {
mockIssuesApi.get.mockResolvedValue(createIssue());
mockIssuesApi.list.mockResolvedValue([
createIssue({
id: "child-1",
parentId: "issue-1",
identifier: "PAP-2",
issueNumber: 2,
title: "Child task",
}),
]);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
const panel = mockOpenPanel.mock.calls.at(-1)?.[0]?.props.children as
{ props?: Record<string, unknown> } | undefined;
expect(panel?.props?.childIssues).toEqual([
expect.objectContaining({ id: "child-1", identifier: "PAP-2" }),
]);
expect(panel?.props?.onAddSubIssue).toEqual(expect.any(Function));
expect(container.textContent).not.toContain("Sub-issues");
expect(mockIssuesListRender).not.toHaveBeenCalled();
});
it("hides the full sub-task tree when the task has no subtasks", async () => {
mockIssuesApi.get.mockResolvedValue(createIssue());
mockIssuesApi.list.mockResolvedValue([]);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
expect(container.textContent).not.toContain("Sub-issues");
expect(mockIssuesListRender.mock.calls).not.toContainEqual([
expect.objectContaining({ isLoading: false }),
]);
});
it("keeps the properties panel stable across unrelated chat-detail renders", async () => {
mockIssuesApi.get.mockResolvedValue(createIssue());
const detail = (
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>
);
await act(async () => {
root.render(detail);
});
await flushReact();
await flushReact();
const panelOpenCount = mockOpenPanel.mock.calls.length;
expect(panelOpenCount).toBeGreaterThan(0);
// React Query returns a new mutation result object on render. The panel
// effect must depend on the stable mutate function rather than that wrapper
// object, or openPanel's state update recursively renders
// IssueDetail until React throws "Maximum update depth exceeded".
await act(async () => {
root.render(detail);
});
await flushReact();
expect(mockOpenPanel).toHaveBeenCalledTimes(panelOpenCount);
});
it("does not loop openPanel when the sub-task list query is still loading (PAP-508)", async () => {
// While the descendant-issues query is still in flight, `data` is undefined.
// A literal `= []` default for that `data` mints a new array reference on
// every render, which destabilizes the child-derived panel key, re-firing
// openPanel each render until
// React throws "Maximum update depth exceeded". Keep the list query pending
// so `data` stays undefined and the stabilization of the empty default is
// the only thing preventing the loop. A fresh root element is rendered each
// pass so React actually re-renders IssueDetail (a reused element reference
// lets the reconciler bail out, masking the loop).
const pendingListRequest = createDeferred<Issue[]>();
mockIssuesApi.get.mockResolvedValue(createIssue());
mockIssuesApi.list.mockReturnValue(pendingListRequest.promise);
const renderDetail = () => (
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>
);
await act(async () => {
root.render(renderDetail());
});
await flushReact();
await flushReact();
const panelOpenCount = mockOpenPanel.mock.calls.length;
expect(panelOpenCount).toBeGreaterThan(0);
await act(async () => {
root.render(renderDetail());
});
await flushReact();
expect(mockOpenPanel).toHaveBeenCalledTimes(panelOpenCount);
pendingListRequest.resolve([]);
await flushReact();
});
it("does not load or render decision sections in the issue header", async () => {
mockIssuesApi.get.mockResolvedValue(
createIssue({
status: "in_review",
reviewAttention: {
state: "covered",
reason: "Review has a maintained action path.",
paths: [
{
kind: "interaction",
label: "Pending request confirmation",
responder: "Board",
since: "2026-04-21T00:00:00.000Z",
ref: "interaction-1",
},
],
},
}),
);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
expect(container.textContent).toContain("Issue detail smoke");
expect(
container.querySelector('[data-testid="issue-review-panel"]'),
).toBeNull();
expect(mockDecisionsApi.list).not.toHaveBeenCalled();
});
it("updates status from the task header control and hides the priority control (PAP-411)", async () => {
const issue = createIssue({ status: "todo", priority: "medium" });
mockIssuesApi.get.mockResolvedValue(issue);
mockIssuesApi.update.mockImplementation(
async (_issueId: string, data: Record<string, unknown>) => ({
...issue,
...data,
}),
);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
const statusButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Change status (current: todo)"]',
);
const priorityButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Change priority (current: medium)"]',
);
expect(statusButton).not.toBeNull();
// PAP-411: priority UI is hidden behind SHOW_TASK_PRIORITY_UI (off), so the header
// priority control must not render.
expect(priorityButton).toBeNull();
await act(async () => {
statusButton!.dispatchEvent(
new MouseEvent("click", { bubbles: true, cancelable: true }),
);
});
await waitForAssertion(() => {
expect(mockIssuesApi.update).toHaveBeenCalledWith(issue.identifier, {
status: "done",
});
});
expect(mockIssuesApi.update).not.toHaveBeenCalledWith(
issue.identifier,
expect.objectContaining({ priority: expect.anything() }),
);
mockIssuesApi.update.mockReset();
});
it("moves a blocked task back to todo when the no-live-path notice tries again", async () => {
const activeRecoveryAction = {
id: "recovery-action-1",
} as NonNullable<Issue["activeRecoveryAction"]>;
const issue = createIssue({
status: "blocked",
assigneeAgentId: "agent-1",
activeRecoveryAction,
});
mockIssuesApi.get.mockResolvedValue(issue);
mockIssuesApi.resolveRecoveryAction.mockResolvedValue({
issue: { ...issue, status: "todo", activeRecoveryAction: null },
recoveryAction: { ...activeRecoveryAction, status: "resolved" },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
const tryAgain = container.querySelector<HTMLButtonElement>(
'[data-testid="mock-no-live-path-try-again"]',
);
expect(tryAgain).not.toBeNull();
await act(async () => {
tryAgain!.click();
});
await waitForAssertion(() => {
expect(mockIssuesApi.resolveRecoveryAction).toHaveBeenCalledWith(
issue.identifier,
{
actionId: activeRecoveryAction.id,
outcome: "restored",
sourceIssueStatus: "todo",
},
);
});
mockIssuesApi.resolveRecoveryAction.mockReset();
});
it("removes an inbox-origin archived issue and restores it when the toast Undo action is pressed", async () => {
const issue = createIssue({
id: "issue-1",
identifier: "PAP-1",
title: "Archive me from detail",
});
const otherIssue = createIssue({
id: "issue-2",
identifier: "PAP-2",
title: "Keep me in inbox",
});
const archiveRequest = createDeferred<{ id: string; archivedAt: Date }>();
mockLocation.state = createIssueDetailLocationState(
"Inbox",
"/inbox/mine",
"inbox",
);
mockIssuesApi.get.mockResolvedValue(issue);
mockIssuesApi.archiveFromInbox.mockReturnValue(archiveRequest.promise);
const mineKey = [
...queryKeys.issues.listMineByMe("company-1"),
"with-routine-executions",
"live-descendant-summary",
] as const;
const compactKey = [
...queryKeys.issues.list("company-1"),
"compact",
"with-routine-executions",
"live-descendant-summary",
] as const;
const touchedKey = [
...queryKeys.issues.listTouchedByMe("company-1"),
"with-routine-executions",
"live-descendant-summary",
] as const;
const unreadKey = queryKeys.issues.listUnreadTouchedByMe("company-1");
queryClient.setQueryData<Issue[]>(mineKey, [issue, otherIssue]);
queryClient.setQueryData<Issue[]>(compactKey, [issue, otherIssue]);
queryClient.setQueryData<Issue[]>(touchedKey, [issue, otherIssue]);
queryClient.setQueryData<Issue[]>(unreadKey, [issue, otherIssue]);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
const moreButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="More task actions"]',
);
await act(async () => moreButton!.click());
const archiveButton =
Array.from(
document.body.querySelectorAll<HTMLButtonElement>("button"),
).find((button) => button.textContent?.trim() === "Archive from inbox") ??
null;
expect(archiveButton).not.toBeNull();
await act(async () => {
archiveButton!.dispatchEvent(
new MouseEvent("click", { bubbles: true, cancelable: true }),
);
});
await waitForAssertion(() => {
expect(
queryClient.getQueryData<Issue[]>(mineKey)?.map((item) => item.id),
).toEqual(["issue-2"]);
expect(
queryClient.getQueryData<Issue[]>(compactKey)?.map((item) => item.id),
).toEqual(["issue-2"]);
expect(
queryClient.getQueryData<Issue[]>(touchedKey)?.map((item) => item.id),
).toEqual(["issue-2"]);
expect(
queryClient.getQueryData<Issue[]>(unreadKey)?.map((item) => item.id),
).toEqual(["issue-2"]);
expect(mockNavigate).not.toHaveBeenCalled();
});
await act(async () => {
archiveRequest.resolve({ id: "issue-1", archivedAt: new Date() });
});
await flushReact();
expect(mockNavigate).toHaveBeenCalledWith("/inbox/mine", { replace: true });
const archiveToast = mockPushToast.mock.calls
.map(([toast]) => toast)
.find((toast) => toast.title === "Task archived from inbox");
expect(archiveToast).toMatchObject({
title: "Task archived from inbox",
tone: "success",
action: { label: "Undo" },
});
expect(archiveToast?.action?.onClick).toEqual(expect.any(Function));
const staleInboxFetch = createDeferred<Issue[]>();
const staleInboxRequest = queryClient
.fetchQuery({
queryKey: mineKey,
queryFn: () => staleInboxFetch.promise,
})
.catch(() => undefined);
const staleCompactFetch = createDeferred<Issue[]>();
const staleCompactRequest = queryClient
.fetchQuery({
queryKey: compactKey,
queryFn: () => staleCompactFetch.promise,
})
.catch(() => undefined);
await waitForAssertion(() => {
expect(queryClient.isFetching({ queryKey: mineKey })).toBe(1);
expect(queryClient.isFetching({ queryKey: compactKey })).toBe(1);
});
await act(async () => {
archiveToast.action.onClick();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
staleInboxFetch.resolve([otherIssue]);
staleCompactFetch.resolve([otherIssue]);
await staleInboxRequest;
await staleCompactRequest;
await waitForAssertion(() => {
expect(mockIssuesApi.unarchiveFromInbox).toHaveBeenCalledWith("issue-1");
expect(
queryClient.getQueryData<Issue[]>(mineKey)?.map((item) => item.id),
).toEqual(["issue-1", "issue-2"]);
expect(
queryClient.getQueryData<Issue[]>(compactKey)?.map((item) => item.id),
).toEqual(["issue-1", "issue-2"]);
expect(
queryClient.getQueryData<Issue[]>(touchedKey)?.map((item) => item.id),
).toEqual(["issue-1", "issue-2"]);
expect(
queryClient.getQueryData<Issue[]>(unreadKey)?.map((item) => item.id),
).toEqual(["issue-1", "issue-2"]);
expect(mockPushToast).toHaveBeenCalledWith({
title: "Task restored to inbox",
tone: "success",
});
});
});
it("keeps an archived task hidden and reports an error when toast Undo fails", async () => {
const issue = createIssue({
id: "issue-1",
identifier: "PAP-1",
title: "Archive me from detail",
});
mockLocation.state = createIssueDetailLocationState(
"Inbox",
"/inbox/mine",
"inbox",
);
mockIssuesApi.get.mockResolvedValue(issue);
mockIssuesApi.unarchiveFromInbox.mockRejectedValue(
new Error("Inbox policy denied"),
);
const mineKey = [
...queryKeys.issues.listMineByMe("company-1"),
"with-routine-executions",
"live-descendant-summary",
] as const;
queryClient.setQueryData<Issue[]>(mineKey, [issue]);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
const moreButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="More task actions"]',
);
await act(async () => moreButton!.click());
const archiveButton =
Array.from(
document.body.querySelectorAll<HTMLButtonElement>("button"),
).find((button) => button.textContent?.trim() === "Archive from inbox") ??
null;
expect(archiveButton).not.toBeNull();
await act(async () => {
archiveButton!.dispatchEvent(
new MouseEvent("click", { bubbles: true, cancelable: true }),
);
});
await waitForAssertion(() => {
expect(queryClient.getQueryData<Issue[]>(mineKey)).toEqual([]);
});
const archiveToast = mockPushToast.mock.calls
.map(([toast]) => toast)
.find((toast) => toast.title === "Task archived from inbox");
expect(archiveToast?.action?.onClick).toEqual(expect.any(Function));
await act(async () => {
archiveToast.action.onClick();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
await waitForAssertion(() => {
expect(mockIssuesApi.unarchiveFromInbox).toHaveBeenCalledWith("issue-1");
expect(queryClient.getQueryData<Issue[]>(mineKey)).toEqual([]);
expect(mockPushToast).toHaveBeenCalledWith({
title: "Undo failed",
body: "Inbox policy denied",
tone: "error",
});
});
});
it("archives a task-page issue with y and returns to the inbox", async () => {
mockLocation.state = createIssueDetailLocationState(
"Tasks",
"/issues/all",
"issues",
);
mockIssuesApi.get.mockResolvedValue(createIssue());
mockInstanceSettingsApi.getGeneral.mockResolvedValue({
keyboardShortcuts: true,
feedbackDataSharingPreference: "prompt",
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
const moreButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="More task actions"]',
);
await act(async () => moreButton!.click());
expect(
Array.from(document.body.querySelectorAll("button")).some(
(button) => button.textContent?.trim() === "Archive from inbox",
),
).toBe(false);
mockIssuesApi.archiveFromInbox.mockClear();
document.dispatchEvent(
new KeyboardEvent("keydown", { key: "y", bubbles: true }),
);
await waitForAssertion(() => {
expect(mockIssuesApi.archiveFromInbox).toHaveBeenCalledWith("issue-1");
expect(mockNavigate).toHaveBeenCalledWith("/inbox", { replace: true });
});
});
it("arms the inbox archive shortcut only for the selected inbox row", async () => {
mockLocation.state = armIssueDetailInboxQuickArchive(
createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox"),
);
mockIssuesApi.get.mockResolvedValue(createIssue());
mockInstanceSettingsApi.getGeneral.mockResolvedValue({
keyboardShortcuts: true,
feedbackDataSharingPreference: "prompt",
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
const panel = mockOpenPanel.mock.calls.at(-1)?.[0]?.props.children as
{ props?: Record<string, unknown> } | undefined;
expect(panel?.props?.issueLinkState).toEqual(
expect.objectContaining({
issueDetailSource: "inbox",
issueDetailInboxQuickArchiveArmed: false,
}),
);
document.dispatchEvent(
new KeyboardEvent("keydown", { key: "y", bubbles: true }),
);
await waitForAssertion(() => {
expect(mockIssuesApi.archiveFromInbox).toHaveBeenCalledWith("issue-1");
});
});
it("uses history Back for a live inbox origin and a route fallback for direct links", async () => {
mockSidebarState.isMobile = true;
mockLocation.state = createIssueDetailLocationState(
"Inbox",
"/inbox/mine",
"inbox",
);
mockIssuesApi.get.mockResolvedValue(createIssue());
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
const liveToolbar = [...mockSetMobileToolbar.mock.calls]
.map(([node]) => node)
.filter(Boolean)
.at(-1) as ReactNode;
const toolbarContainer = document.createElement("div");
document.body.appendChild(toolbarContainer);
const toolbarRoot = createRoot(toolbarContainer);
flushSync(() => toolbarRoot.render(liveToolbar));
const historyLengthSpy = vi
.spyOn(window.history, "length", "get")
.mockReturnValue(2);
await act(async () => {
toolbarContainer
.querySelector<HTMLButtonElement>('button[aria-label="Back to inbox"]')!
.click();
});
expect(mockNavigate).toHaveBeenCalledWith(-1);
historyLengthSpy.mockRestore();
flushSync(() => toolbarRoot.unmount());
toolbarContainer.remove();
mockNavigate.mockClear();
mockSetMobileToolbar.mockClear();
mockLocation.state = null;
mockLocation.search = "?from=inbox&fromHref=%2Finbox%2Fmine";
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
mockNavigate.mockClear();
const directToolbar = [...mockSetMobileToolbar.mock.calls]
.map(([node]) => node)
.filter(Boolean)
.at(-1) as ReactNode;
const directToolbarContainer = document.createElement("div");
document.body.appendChild(directToolbarContainer);
const directToolbarRoot = createRoot(directToolbarContainer);
flushSync(() => directToolbarRoot.render(directToolbar));
await act(async () => {
directToolbarContainer
.querySelector<HTMLButtonElement>('button[aria-label="Back to inbox"]')!
.click();
});
expect(mockNavigate).toHaveBeenCalledWith("/inbox/mine");
flushSync(() => directToolbarRoot.unmount());
directToolbarContainer.remove();
});
it("shows assignee and originating avatars in the issue header metadata", async () => {
mockIssuesApi.get.mockResolvedValue(
createIssue({
assigneeAgentId: "agent-1",
projectId: "project-1",
createdByUserId: "user-1",
}),
);
mockAgentsApi.list.mockResolvedValue([createAgent({ name: "CodexCoder" })]);
mockProjectsApi.list.mockResolvedValue([
{ id: "project-1", name: "Core Product", color: "#2563eb" },
]);
mockAccessApi.listUserDirectory.mockResolvedValue({
users: [
{
principalId: "user-1",
status: "active",
user: {
id: "user-1",
name: "Dotta",
email: "dotta@example.com",
image: null,
},
},
],
});
mockAuthApi.getSession.mockResolvedValue({
session: { userId: "user-1" },
user: { id: "user-1" },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
await waitForAssertion(() => {
const avatarStack = container.querySelector(
'[data-testid="issue-attribution-avatar-stack"]',
);
const assigneeAvatar = container.querySelector(
'[data-testid="issue-assignee-avatar"]',
);
const originatingAvatar = container.querySelector(
'[data-testid="issue-originating-avatar"]',
);
expect(container.textContent).toContain("Core Product");
expect(avatarStack).toBeTruthy();
expect(assigneeAvatar?.getAttribute("aria-label")).toBe(
"Assignee: CodexCoder",
);
expect(originatingAvatar?.getAttribute("aria-label")).toBe(
"Originating: Dotta",
);
expect(assigneeAvatar?.getAttribute("title")).toBeNull();
expect(originatingAvatar?.getAttribute("title")).toBeNull();
expect(avatarStack?.textContent).not.toContain("Assignee");
expect(avatarStack?.textContent).not.toContain("Originating");
expect(avatarStack?.textContent).not.toContain("CodexCoder");
expect(avatarStack?.textContent).not.toContain("Dotta");
});
const pointerEvent = window.PointerEvent ?? MouseEvent;
const assigneeAvatar = container.querySelector(
'[data-testid="issue-assignee-avatar"]',
);
const originatingAvatar = container.querySelector(
'[data-testid="issue-originating-avatar"]',
);
await act(async () => {
assigneeAvatar?.dispatchEvent(
new pointerEvent("pointermove", { bubbles: true }),
);
});
await waitForAssertion(() => {
const tooltip = document.body.querySelector(
'[data-testid="issue-assignee-tooltip"]',
);
expect(tooltip?.textContent).toContain("Assignee");
expect(tooltip?.textContent).toContain("CodexCoder");
});
await act(async () => {
originatingAvatar?.dispatchEvent(
new pointerEvent("pointermove", { bubbles: true }),
);
});
await waitForAssertion(() => {
const tooltip = document.body.querySelector(
'[data-testid="issue-originating-tooltip"]',
);
expect(tooltip?.textContent).toContain("Originating");
expect(tooltip?.textContent).toContain("Dotta");
});
});
it("attributes an agent-created issue to the transitive responsible user with a via affordance", async () => {
mockIssuesApi.get.mockResolvedValue(
createIssue({
assigneeAgentId: "agent-1",
createdByAgentId: "agent-1",
createdByUserId: null,
responsibleUserId: "user-1",
}),
);
mockAgentsApi.list.mockResolvedValue([createAgent({ name: "CodexCoder" })]);
mockAccessApi.listUserDirectory.mockResolvedValue({
users: [
{
principalId: "user-1",
status: "active",
user: {
id: "user-1",
name: "Dotta",
email: "dotta@example.com",
image: null,
},
},
],
});
mockAuthApi.getSession.mockResolvedValue({
session: { userId: "user-1" },
user: { id: "user-1" },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
await waitForAssertion(() => {
const originatingAvatar = container.querySelector(
'[data-testid="issue-originating-avatar"]',
);
expect(originatingAvatar?.getAttribute("aria-label")).toBe(
"Originating: Dotta · via CodexCoder",
);
});
const pointerEvent = window.PointerEvent ?? MouseEvent;
const originatingAvatar = container.querySelector(
'[data-testid="issue-originating-avatar"]',
);
await act(async () => {
originatingAvatar?.dispatchEvent(
new pointerEvent("pointermove", { bubbles: true }),
);
});
await waitForAssertion(() => {
const tooltip = document.body.querySelector(
'[data-testid="issue-originating-tooltip"]',
);
expect(tooltip?.textContent).toContain("Dotta");
expect(tooltip?.textContent).toContain("via CodexCoder");
});
});
it("does not mark the wake comment for the current live run as queued when active-run cache is stale", async () => {
mockIssuesApi.get.mockResolvedValue(
createIssue({
status: "in_progress",
executionRunId: "run-stale",
}),
);
mockIssuesApi.listComments.mockResolvedValue([
createIssueComment({
id: "comment-fresh",
createdAt: new Date("2026-04-21T00:00:05.000Z"),
updatedAt: new Date("2026-04-21T00:00:05.000Z"),
}),
]);
mockHeartbeatsApi.activeRunForIssue.mockResolvedValue({
id: "run-stale",
status: "running",
invocationSource: "issue",
triggerDetail: null,
contextCommentId: null,
contextWakeCommentId: null,
startedAt: "2026-04-21T00:00:00.000Z",
finishedAt: null,
createdAt: "2026-04-21T00:00:00.000Z",
agentId: "agent-1",
agentName: "Coder",
adapterType: "codex_local",
issueId: "issue-1",
});
mockHeartbeatsApi.liveRunsForIssue.mockResolvedValue([
{
id: "run-current",
status: "running",
invocationSource: "issue",
triggerDetail: null,
contextCommentId: "comment-fresh",
contextWakeCommentId: "comment-fresh",
startedAt: "2026-04-21T00:00:01.000Z",
finishedAt: null,
createdAt: "2026-04-21T00:00:01.000Z",
agentId: "agent-1",
agentName: "Coder",
adapterType: "codex_local",
issueId: "issue-1",
},
]);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
const props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as {
comments?: Array<{ id: string; queueState?: string }>;
};
const freshComment = props.comments?.find(
(comment) => comment.id === "comment-fresh",
);
expect(freshComment?.queueState).toBeUndefined();
});
it.each(["missing_activity", "conflicting_activity"])(
"keeps the persisted authoring run for private Board replies: %s",
async (mode) => {
mockIssuesApi.get.mockResolvedValue(
createIssue({ status: "in_progress" }),
);
mockIssuesApi.listComments.mockResolvedValue([
createIssueComment({
id: "private-board-answer",
authorType: "agent",
authorAgentId: "agent-1",
authorUserId: null,
createdByRunId: "run-private-board",
body: "Object: lighthouse. Accent color: amber. Count: 63.",
}),
createIssueComment({
id: "distinct-human-answer",
body: "Object: lighthouse. Accent color: amber. Count: 63.",
}),
]);
mockActivityApi.runsForIssue.mockResolvedValue([
{
runId: "run-private-board",
agentId: "agent-1",
agentName: "Runner",
adapterType: "paperclip_runner",
runtimeMode: "native",
status: "succeeded",
createdAt: "2026-04-21T00:00:00.000Z",
startedAt: "2026-04-21T00:00:00.000Z",
finishedAt: "2026-04-21T00:00:02.000Z",
contextIssueId: "issue-1",
resultJson: {
presentationDecision: {
schema: "paperclip.run_presentation_decision.v1",
chosenSource: "existing_issue_comment",
commentId: "private-board-answer",
},
},
logBytes: 1,
},
]);
if (mode === "conflicting_activity") {
mockActivityApi.forIssue.mockResolvedValue([
{
action: "issue.comment_added",
runId: "run-other",
agentId: "agent-other",
details: {
commentId: "private-board-answer",
interruptedRunId: "run-unrelated-interruption",
},
},
]);
}
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
const props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as {
comments?: Array<Record<string, unknown>>;
};
expect(props.comments).toHaveLength(2);
expect(
props.comments?.find(
(comment) => comment.id === "private-board-answer",
),
).toMatchObject({
createdByRunId: "run-private-board",
runId: "run-private-board",
runAgentId: "agent-1",
interruptedRunId: null,
});
expect(
props.comments?.find(
(comment) => comment.id === "distinct-human-answer",
)?.runId,
).toBeUndefined();
},
);
it("recovers historical follow-up provenance from overlapping run chronology", async () => {
mockIssuesApi.get.mockResolvedValue(createIssue({ status: "done" }));
mockIssuesApi.listComments.mockResolvedValue([
createIssueComment({
id: "comment-follow-up",
body: "Use three seconds instead.",
createdAt: new Date("2026-04-21T00:00:30.000Z"),
updatedAt: new Date("2026-04-21T00:00:30.000Z"),
}),
]);
mockActivityApi.runsForIssue.mockResolvedValue([
{
runId: "run-original",
agentId: "agent-1",
agentName: "Runner",
adapterType: "paperclip_runner",
status: "succeeded",
createdAt: "2026-04-21T00:00:00.000Z",
startedAt: "2026-04-21T00:00:00.000Z",
finishedAt: "2026-04-21T00:01:00.000Z",
contextIssueId: "issue-1",
logBytes: 1,
},
{
runId: "run-successor",
agentId: "agent-1",
agentName: "Runner",
adapterType: "paperclip_runner",
status: "succeeded",
createdAt: "2026-04-21T00:01:01.000Z",
startedAt: "2026-04-21T00:01:01.000Z",
finishedAt: "2026-04-21T00:01:04.000Z",
wakeCommentId: "comment-follow-up",
wakeCommentIds: ["comment-follow-up"],
contextCommentId: "comment-follow-up",
contextIssueId: "issue-1",
logBytes: 1,
},
]);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
await waitForAssertion(() => {
const props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as {
comments?: Array<Record<string, unknown>>;
};
expect(
props.comments?.find((comment) => comment.id === "comment-follow-up"),
).toMatchObject({
followUpRequested: true,
consumedByRunId: "run-successor",
conversationAnchorAt: "2026-04-21T00:01:01.000Z",
});
});
});
it("does not infer follow-up provenance from an unrelated overlapping linked run", async () => {
mockIssuesApi.get.mockResolvedValue(createIssue({ status: "done" }));
mockIssuesApi.listComments.mockResolvedValue([
createIssueComment({
id: "comment-ordinary",
body: "Start this ordinary run.",
createdAt: new Date("2026-04-21T00:00:30.000Z"),
updatedAt: new Date("2026-04-21T00:00:30.000Z"),
}),
]);
mockActivityApi.runsForIssue.mockResolvedValue([
{
runId: "run-unrelated",
agentId: "agent-1",
agentName: "Runner",
adapterType: "paperclip_runner",
status: "succeeded",
createdAt: "2026-04-21T00:00:00.000Z",
startedAt: "2026-04-21T00:00:00.000Z",
finishedAt: "2026-04-21T00:01:00.000Z",
contextIssueId: "another-issue",
logBytes: 1,
},
{
runId: "run-ordinary",
agentId: "agent-1",
agentName: "Runner",
adapterType: "paperclip_runner",
status: "succeeded",
createdAt: "2026-04-21T00:01:01.000Z",
startedAt: "2026-04-21T00:01:01.000Z",
finishedAt: "2026-04-21T00:01:04.000Z",
wakeCommentId: "comment-ordinary",
wakeCommentIds: ["comment-ordinary"],
contextCommentId: "comment-ordinary",
contextIssueId: "issue-1",
logBytes: 1,
},
]);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
await waitForAssertion(() => {
const props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as {
comments?: Array<Record<string, unknown>>;
};
expect(
props.comments?.find((comment) => comment.id === "comment-ordinary"),
).toMatchObject({
consumedByRunId: "run-ordinary",
conversationAnchorAt: "2026-04-21T00:01:01.000Z",
});
expect(
props.comments?.find((comment) => comment.id === "comment-ordinary")
?.followUpRequested,
).toBeUndefined();
});
});
it("recovers follow-up provenance across an intervening activity-linked run", async () => {
mockIssuesApi.get.mockResolvedValue(createIssue({ status: "done" }));
mockIssuesApi.listComments.mockResolvedValue([
createIssueComment({
id: "comment-follow-up",
body: "Deliver this after the active run.",
createdAt: new Date("2026-04-21T00:00:30.000Z"),
updatedAt: new Date("2026-04-21T00:00:30.000Z"),
}),
]);
mockActivityApi.runsForIssue.mockResolvedValue([
{
runId: "run-source",
agentId: "agent-1",
agentName: "Runner",
adapterType: "paperclip_runner",
status: "succeeded",
createdAt: "2026-04-21T00:00:00.000Z",
startedAt: "2026-04-21T00:00:00.000Z",
finishedAt: "2026-04-21T00:01:00.000Z",
contextIssueId: "issue-1",
logBytes: 1,
},
{
runId: "run-intervening",
agentId: "agent-1",
agentName: "Runner",
adapterType: "paperclip_runner",
status: "succeeded",
createdAt: "2026-04-21T00:00:40.000Z",
startedAt: "2026-04-21T00:00:40.000Z",
finishedAt: "2026-04-21T00:01:10.000Z",
contextIssueId: "another-issue",
logBytes: 1,
},
{
runId: "run-successor",
agentId: "agent-1",
agentName: "Runner",
adapterType: "paperclip_runner",
status: "succeeded",
createdAt: "2026-04-21T00:01:11.000Z",
startedAt: "2026-04-21T00:01:11.000Z",
finishedAt: "2026-04-21T00:01:14.000Z",
wakeCommentId: "comment-follow-up",
wakeCommentIds: ["comment-follow-up"],
contextCommentId: "comment-follow-up",
contextIssueId: "issue-1",
logBytes: 1,
},
]);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
await waitForAssertion(() => {
const props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as {
comments?: Array<Record<string, unknown>>;
};
expect(
props.comments?.find((comment) => comment.id === "comment-follow-up"),
).toMatchObject({
followUpRequested: true,
consumedByRunId: "run-successor",
conversationAnchorAt: "2026-04-21T00:01:11.000Z",
});
});
});
it("keeps acknowledged question answers at their submission time while recording successor delivery", async () => {
mockIssuesApi.get.mockResolvedValue(
createIssue({
status: "in_progress",
assigneeAgentId: "agent-1",
executionRunId: "run-successor",
}),
);
mockIssuesApi.listInteractions.mockResolvedValue([
{
id: "interaction-answers",
companyId: "company-1",
issueId: "issue-1",
kind: "ask_user_questions",
status: "answered",
continuationPolicy: "wake_assignee",
resolverPolicy: "anyone",
requestedResolverPolicy: "anyone",
effectiveResolverPolicy: "anyone",
resolverPolicyProvenance: "inherited",
effectiveResolverPolicySource: "requested",
legacyResolverPolicyAliases: { requested: null, effective: null },
sourceRunId: "run-source",
resolvedByUserId: "user-1",
createdAt: "2026-04-21T00:00:01.000Z",
updatedAt: "2026-04-21T00:00:04.000Z",
resolvedAt: "2026-04-21T00:00:04.000Z",
payload: {
version: 1,
questions: [
{
id: "runtime",
prompt: "Which runtime?",
selectionMode: "single",
options: [{ id: "node", label: "Node.js" }],
},
],
questionSet: {
schema: "paperclip.question_set.v1",
questions: [
{
id: "runtime",
header: "Runtime",
prompt: "Which runtime?",
required: true,
answerMode: "single_select",
options: [{ id: "node", label: "Node.js" }],
},
],
},
},
result: {
version: 1,
answers: [{ questionId: "runtime", optionIds: ["node"] }],
},
},
]);
mockActivityApi.forIssue.mockResolvedValue([
{
id: "activity-answer-delivered",
companyId: "company-1",
actorType: "system",
actorId: "question-response-delivery",
agentId: null,
runId: "run-successor",
action: "issue.question_response_delivered",
entityType: "issue",
entityId: "issue-1",
details: {
interactionId: "interaction-answers",
sourceRunId: "run-source",
targetRunId: "run-successor",
targetTurnId: "turn-successor",
deliveryMode: "steered",
},
createdAt: "2026-04-21T00:00:04.000Z",
},
]);
mockActivityApi.runsForIssue.mockResolvedValue([
{
runId: "run-successor",
agentId: "agent-1",
agentName: "Runner",
adapterType: "codex_local",
status: "running",
createdAt: "2026-04-21T00:00:02.000Z",
startedAt: "2026-04-21T00:00:02.000Z",
finishedAt: null,
wakeCommentId: "comment-queued",
wakeCommentIds: ["comment-queued"],
contextCommentId: "comment-queued",
logBytes: 1,
},
]);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
await waitForAssertion(() => {
const props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as {
comments?: Array<Record<string, unknown>>;
interactions?: Array<Record<string, unknown>>;
};
const delivered = props.comments?.find(
(comment) => comment.id === "interaction-response:interaction-answers",
);
expect(delivered).toMatchObject({
body: "Answered questions\n\n- Runtime — Which runtime?: Node.js",
createdAt: new Date("2026-04-21T00:00:04.000Z"),
updatedAt: new Date("2026-04-21T00:00:04.000Z"),
consumedByRunId: "run-successor",
steeredIntoRunId: "run-successor",
conversationAnchorAt: new Date("2026-04-21T00:00:04.000Z"),
});
expect(
props.comments?.filter((comment) =>
String(comment.id).startsWith("interaction-response:"),
),
).toHaveLength(1);
expect(
props.interactions?.find(
(interaction) => interaction.id === "interaction-answers",
),
).toMatchObject({
status: "answered",
sourceRunId: "run-source",
});
});
});
it.each(["acpx_local", "claude_local", "codex_local"])(
"projects a resolved plan decision as queued for an active %s legacy run",
async (adapterType) => {
mockIssuesApi.get.mockResolvedValue(
createIssue({
status: "in_progress",
assigneeAgentId: "agent-1",
executionRunId: "run-source",
}),
);
mockIssuesApi.listInteractions.mockResolvedValue([
{
id: "interaction-plan",
companyId: "company-1",
issueId: "issue-1",
kind: "request_confirmation",
status: "accepted",
continuationPolicy: "wake_assignee",
resolverPolicy: "anyone",
requestedResolverPolicy: "anyone",
effectiveResolverPolicy: "anyone",
resolverPolicyProvenance: "inherited",
effectiveResolverPolicySource: "requested",
legacyResolverPolicyAliases: { requested: null, effective: null },
sourceRunId: "run-source",
resolvedByUserId: "user-1",
createdAt: "2026-04-21T00:00:01.000Z",
updatedAt: "2026-04-21T00:00:04.000Z",
resolvedAt: "2026-04-21T00:00:04.000Z",
payload: {
version: 1,
prompt: "Approve this plan?",
target: {
type: "issue_document",
issueId: "issue-1",
documentId: "document-plan",
key: "plan",
revisionId: "revision-1",
revisionNumber: 1,
label: "Plan revision 1",
},
},
result: { outcome: "accepted" },
},
]);
const activeRun = {
id: "run-source",
status: "running",
invocationSource: "issue",
triggerDetail: null,
contextCommentId: null,
contextWakeCommentId: null,
startedAt: "2026-04-21T00:00:00.000Z",
finishedAt: null,
createdAt: "2026-04-21T00:00:00.000Z",
agentId: "agent-1",
agentName: "Coder",
adapterType,
issueId: "issue-1",
};
mockHeartbeatsApi.activeRunForIssue.mockResolvedValue(activeRun);
mockHeartbeatsApi.liveRunsForIssue.mockResolvedValue([activeRun]);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
await waitForAssertion(() => {
const props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as {
comments?: Array<Record<string, unknown>>;
};
expect(
props.comments?.find(
(comment) => comment.id === "interaction-response:interaction-plan",
),
).toMatchObject({
authorType: "user",
authorUserId: "user-1",
body: "Approved plan",
createdAt: new Date("2026-04-21T00:00:04.000Z"),
conversationAnchorAt: new Date("2026-04-21T00:00:04.000Z"),
queueState: "queued",
queueTargetRunId: "run-source",
queueReason: "active_run",
});
});
},
);
it("queues messages against a queued live run and interrupts that exact run", async () => {
const postedComment = createDeferred<IssueComment>();
mockIssuesApi.get.mockResolvedValue(
createIssue({
status: "in_progress",
executionRunId: "run-queued",
}),
);
mockIssuesApi.addComment.mockReturnValue(postedComment.promise);
mockIssuesApi.getQueuedComments.mockResolvedValue(
createQueuedCommentQueue({
queueId: null,
state: null,
targetRunId: null,
protocol: "legacy",
steeringDisposition: "unsupported",
entries: [],
}),
);
mockHeartbeatsApi.cancel.mockResolvedValue({});
mockHeartbeatsApi.liveRunsForIssue.mockResolvedValue([
{
id: "run-queued",
status: "queued",
invocationSource: "issue",
triggerDetail: null,
contextCommentId: null,
contextWakeCommentId: null,
startedAt: null,
finishedAt: null,
createdAt: "2026-04-21T00:00:01.000Z",
agentId: "agent-1",
agentName: "Coder",
adapterType: "codex_local",
issueId: "issue-1",
},
]);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
const props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as {
onAdd: (body: string) => Promise<void>;
};
await act(async () => {
void props.onAdd("Queued run message");
await Promise.resolve();
});
await flushReact();
const queuedProps = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as {
comments?: Array<{
body: string;
clientStatus?: string;
queueState?: string;
queueTargetRunId?: string | null;
}>;
queuedCommentQueue?: IssueQueuedCommentQueue | null;
onInterruptQueued: (runId: string) => Promise<void>;
};
const optimisticComment = queuedProps.comments?.find(
(comment) => comment.body === "Queued run message",
);
expect(optimisticComment).toMatchObject({
clientStatus: "queued",
queueState: "queued",
queueTargetRunId: "run-queued",
});
expect(queuedProps.queuedCommentQueue).toMatchObject({
queueId: null,
targetRunId: "run-queued",
protocol: "legacy",
entries: [
expect.objectContaining({
comment: expect.objectContaining({ body: "Queued run message" }),
}),
],
});
await act(async () => {
postedComment.resolve(createIssueComment({ body: "Queued run message" }));
});
await flushReact();
const persistedProps = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as {
comments?: Array<{
body: string;
clientStatus?: string;
queueState?: string;
queueTargetRunId?: string | null;
}>;
queuedCommentQueue?: IssueQueuedCommentQueue | null;
onInterruptQueued: (runId: string) => Promise<void>;
};
const persistedComment = persistedProps.comments?.find(
(comment) => comment.body === "Queued run message",
);
expect(persistedComment).toMatchObject({
queueState: "queued",
queueTargetRunId: "run-queued",
});
expect(
persistedProps.queuedCommentQueue?.entries[0]?.comment,
).toMatchObject({
id: "comment-1",
body: "Queued run message",
});
mockIssuesApi.getQueuedComments.mockResolvedValue(createQueuedCommentQueue({
targetRunId: "run-queued", protocol: "legacy", steeringDisposition: "unsupported",
}));
await act(async () => {
await persistedProps.onInterruptQueued(
persistedComment!.queueTargetRunId!,
);
});
expect(mockIssuesApi.interruptQueuedComments).toHaveBeenCalledWith("PAP-1", {
queueId: "wake-queue-1", revision: "queue-revision-1", targetRunId: "run-queued",
});
expect(mockHeartbeatsApi.cancel).not.toHaveBeenCalled();
});
it("projects a native follow-up into the steering well before the post resolves", async () => {
const postedComment = createDeferred<IssueComment>();
const activeRun = {
id: "run-native",
runtimeMode: "native" as const,
status: "running",
invocationSource: "issue",
triggerDetail: null,
contextCommentId: null,
contextWakeCommentId: null,
startedAt: "2026-04-21T00:00:01.000Z",
finishedAt: null,
createdAt: "2026-04-21T00:00:01.000Z",
agentId: "agent-1",
agentName: "Runner",
adapterType: "paperclip_runner",
issueId: "issue-1",
};
mockIssuesApi.get.mockResolvedValue(
createIssue({
status: "in_progress",
assigneeAgentId: "agent-1",
executionRunId: activeRun.id,
}),
);
mockAgentsApi.list.mockResolvedValue([
createAgent({ adapterType: "paperclip_runner" }),
]);
mockHeartbeatsApi.activeRunForIssue.mockResolvedValue(activeRun);
mockHeartbeatsApi.liveRunsForIssue.mockResolvedValue([activeRun]);
mockIssuesApi.getQueuedComments.mockResolvedValue(
createQueuedCommentQueue({
queueId: null,
state: null,
targetRunId: null,
entries: [],
steeringDisposition: "temporarily_unavailable",
}),
);
mockIssuesApi.addComment.mockReturnValue(postedComment.promise);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
const initialProps = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as {
onAdd: (body: string) => Promise<void>;
};
await act(async () => {
void initialProps.onAdd("Use the newer direction");
await Promise.resolve();
});
await flushReact();
const pendingProps = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as {
queuedCommentQueue?: IssueQueuedCommentQueue | null;
};
expect(pendingProps.queuedCommentQueue).toMatchObject({
queueId: null,
targetRunId: "run-native",
protocol: "paperclip_runner_v1",
entries: [
expect.objectContaining({
comment: expect.objectContaining({
id: expect.stringMatching(/^optimistic-/),
body: "Use the newer direction",
}),
}),
],
});
await act(async () => {
postedComment.resolve(
createIssueComment({
id: "native-follow-up",
body: "Use the newer direction",
}),
);
});
await flushReact();
const acknowledgedProps = mockIssueChatThreadRender.mock.calls.at(
-1,
)?.[0] as {
queuedCommentQueue?: IssueQueuedCommentQueue | null;
};
expect(
acknowledgedProps.queuedCommentQueue?.entries[0]?.comment,
).toMatchObject({
id: "native-follow-up",
body: "Use the newer direction",
});
});
it("does not rebind a queued message when another run becomes live before its request settles", async () => {
const postedComment = createDeferred<IssueComment>();
mockIssuesApi.get.mockResolvedValue(
createIssue({
status: "in_progress",
executionRunId: "run-original",
}),
);
mockIssuesApi.addComment.mockReturnValue(postedComment.promise);
mockHeartbeatsApi.cancel.mockResolvedValue({});
mockHeartbeatsApi.liveRunsForIssue.mockResolvedValue([
{
id: "run-original",
status: "running",
invocationSource: "issue",
triggerDetail: null,
contextCommentId: null,
contextWakeCommentId: null,
startedAt: "2026-04-21T00:00:01.000Z",
finishedAt: null,
createdAt: "2026-04-21T00:00:01.000Z",
agentId: "agent-1",
agentName: "Coder",
adapterType: "codex_local",
issueId: "issue-1",
},
]);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
const initialProps = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as {
onAdd: (body: string) => Promise<void>;
};
await act(async () => {
void initialProps.onAdd("Keep this bound to the original run");
await Promise.resolve();
});
await flushReact();
const replacementRun = {
id: "run-replacement",
status: "running" as const,
invocationSource: "issue" as const,
triggerDetail: null,
contextCommentId: null,
contextWakeCommentId: null,
startedAt: "2026-04-21T00:00:02.000Z",
finishedAt: null,
createdAt: "2026-04-21T00:00:02.000Z",
agentId: "agent-1",
agentName: "Coder",
adapterType: "codex_local",
issueId: "issue-1",
};
await act(async () => {
queryClient.setQueryData(queryKeys.issues.liveRuns("issue-1"), [
replacementRun,
]);
queryClient.setQueryData(
queryKeys.issues.activeRun("issue-1"),
replacementRun,
);
});
await flushReact();
const replacementProps = mockIssueChatThreadRender.mock.calls.at(
-1,
)?.[0] as {
comments?: Array<{
body: string;
clientStatus?: string;
queueState?: string;
queueTargetRunId?: string | null;
}>;
onInterruptQueued: (runId: string) => Promise<void>;
};
const optimisticComment = replacementProps.comments?.find(
(comment) => comment.body === "Keep this bound to the original run",
);
expect(optimisticComment).toMatchObject({
clientStatus: "queued",
queueTargetRunId: "run-original",
});
mockIssuesApi.getQueuedComments.mockResolvedValue(createQueuedCommentQueue({
targetRunId: "run-replacement", protocol: "legacy", steeringDisposition: "unsupported",
}));
await act(async () => {
await expect(replacementProps.onInterruptQueued(
optimisticComment!.queueTargetRunId!,
)).rejects.toThrow("The queued messages changed");
});
expect(mockIssuesApi.interruptQueuedComments).not.toHaveBeenCalled();
expect(mockHeartbeatsApi.cancel).not.toHaveBeenCalled();
await act(async () => {
postedComment.resolve(
createIssueComment({ body: "Keep this bound to the original run" }),
);
});
await flushReact();
mockHeartbeatsApi.cancel.mockClear();
});
it("does not optimistically queue a fresh comment from an unlocked stale active-run cache", async () => {
const postedComment = createDeferred<IssueComment>();
mockIssuesApi.get.mockResolvedValue(
createIssue({
status: "todo",
executionRunId: null,
}),
);
mockIssuesApi.addComment.mockReturnValue(postedComment.promise);
queryClient.setQueryData(queryKeys.issues.activeRun("PAP-1"), {
id: "run-stale",
status: "running",
invocationSource: "issue",
triggerDetail: null,
contextCommentId: null,
contextWakeCommentId: null,
startedAt: "2026-04-21T00:00:00.000Z",
finishedAt: null,
createdAt: "2026-04-21T00:00:00.000Z",
agentId: "agent-1",
agentName: "Coder",
adapterType: "codex_local",
issueId: "issue-1",
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
const props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as {
onAdd: (body: string) => Promise<void>;
comments?: Array<{
body: string;
clientStatus?: string;
queueState?: string;
}>;
};
await act(async () => {
void props.onAdd("Fresh comment");
await Promise.resolve();
});
await flushReact();
const nextProps = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as {
comments?: Array<{
body: string;
clientStatus?: string;
queueState?: string;
}>;
};
const optimisticComment = nextProps.comments?.find(
(comment) => comment.body === "Fresh comment",
);
expect(optimisticComment).toMatchObject({ clientStatus: "pending" });
expect(optimisticComment?.queueState).toBeUndefined();
const postedAt = new Date("2026-04-21T00:00:10.000Z");
await act(async () => {
postedComment.resolve(
createIssueComment({
body: "Fresh comment",
createdAt: postedAt,
updatedAt: postedAt,
}),
);
});
await flushReact();
expect(
readRecentTasks(
getRecentTasksStorageKey("company-1", null),
"company-1",
)[0],
).toMatchObject({
id: "issue-1",
recordedAt: postedAt.getTime(),
});
});
it("hides the plan decomposition panel by default", async () => {
mockIssuesApi.get.mockResolvedValue(createIssue());
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
expect(container.textContent).not.toContain("Plan decomposition");
expect(mockIssuesApi.listAcceptedPlanDecompositions).not.toHaveBeenCalled();
});
it("hides file viewer entry points by default", async () => {
mockIssuesApi.get.mockResolvedValue(createIssue());
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
expect(
container.querySelector('[aria-label="Open file in this issue"]'),
).toBeNull();
});
it("shows file viewer entry points when the experimental flag is enabled", async () => {
mockIssuesApi.get.mockResolvedValue(createIssue());
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIssuePlanDecompositions: false,
enableExperimentalFileViewer: true,
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
expect(
container.querySelector('[aria-label="Open file in this issue"]'),
).not.toBeNull();
});
it("hides the properties sidebar on the first onboarding task until a plan document exists", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIssuePlanDecompositions: false,
enableExperimentalFileViewer: false,
enableExternalObjects: false,
});
mockIssuesApi.get.mockResolvedValue(
createIssue({ originKind: ONBOARDING_FIRST_TASK_ORIGIN_KIND }),
);
// No plan yet: the hook's 404 resolves to null.
mockIssuesApi.getDocument.mockResolvedValue(null);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
// Panel content is withheld — openPanel is never invoked, so the sidebar
// stays hidden without touching the persisted panelVisible preference.
expect(mockOpenPanel).not.toHaveBeenCalled();
expect(mockClosePanel).toHaveBeenCalled();
});
it("starts a planning-mode task as chat-only until its plan document exists", async () => {
mockSetBreadcrumbToolbar.mockClear();
mockSetBreadcrumbPanelControl.mockClear();
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIssuePlanDecompositions: false,
enableExperimentalFileViewer: false,
enableExternalObjects: false,
});
mockIssuesApi.get.mockResolvedValue(
createIssue({
originKind: "manual",
workMode: "planning",
}),
);
mockIssuesApi.getDocument.mockResolvedValue(null);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
expect(mockOpenPanel).not.toHaveBeenCalled();
expect(mockClosePanel).toHaveBeenCalled();
expect(
container.querySelector('button[aria-label="Toggle side panel"]'),
).toBeNull();
const toolbar = [...mockSetBreadcrumbToolbar.mock.calls]
.reverse()
.map(([node]) => node)
.find((node) => node !== null);
expect(toolbar).toBeUndefined();
const panelControl = [...mockSetBreadcrumbPanelControl.mock.calls]
.reverse()
.map(([control]) => control)
.find((control) => control !== null);
expect(panelControl).toMatchObject({ open: false });
});
it("reveals the planning-mode task sidebar when its plan document exists", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIssuePlanDecompositions: false,
enableExperimentalFileViewer: false,
enableExternalObjects: false,
});
mockIssuesApi.get.mockResolvedValue(
createIssue({
originKind: "manual",
workMode: "planning",
}),
);
mockIssuesApi.getDocument.mockResolvedValue({ id: "doc-1", key: "plan" });
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await waitForAssertion(() => {
expect(mockOpenPanel).toHaveBeenCalled();
});
});
it("keeps the Show properties button clickable on the first task and reveals the sidebar on demand", async () => {
mockSetBreadcrumbToolbar.mockClear();
mockSetBreadcrumbPanelControl.mockClear();
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIssuePlanDecompositions: false,
enableExperimentalFileViewer: false,
enableExternalObjects: false,
});
mockIssuesApi.get.mockResolvedValue(
createIssue({ originKind: ONBOARDING_FIRST_TASK_ORIGIN_KIND }),
);
// No plan yet: the panel mount is suppressed by default.
mockIssuesApi.getDocument.mockResolvedValue(null);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
expect(mockOpenPanel).not.toHaveBeenCalled();
// Even though panelVisible is true, the suppressed first task routes the
// single breadcrumb panel control through its per-task opt-in behavior.
const panelControl = [...mockSetBreadcrumbPanelControl.mock.calls]
.reverse()
.map(([control]) => control)
.find((control) => control !== null) as {
open: boolean;
onToggle: () => void;
};
expect(panelControl).toMatchObject({ open: false });
expect(
mockSetBreadcrumbToolbar.mock.calls.every(([node]) => node === null),
).toBe(true);
await act(async () => {
panelControl.onToggle();
});
await flushReact();
// The click overrides the first-task suppression and mounts the panel.
await waitForAssertion(() => {
expect(mockOpenPanel).toHaveBeenCalled();
});
});
it("reveals the properties sidebar on the first onboarding task once a plan document exists", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIssuePlanDecompositions: false,
enableExperimentalFileViewer: false,
enableExternalObjects: false,
});
mockIssuesApi.get.mockResolvedValue(
createIssue({ originKind: ONBOARDING_FIRST_TASK_ORIGIN_KIND }),
);
mockIssuesApi.getDocument.mockResolvedValue({ id: "doc-1", key: "plan" });
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await waitForAssertion(() => {
expect(mockOpenPanel).toHaveBeenCalled();
});
});
it("shows the properties sidebar immediately on a non-first task", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIssuePlanDecompositions: false,
enableExperimentalFileViewer: false,
enableExternalObjects: false,
});
mockIssuesApi.get.mockResolvedValue(createIssue({ originKind: "manual" }));
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await waitForAssertion(() => {
expect(mockOpenPanel).toHaveBeenCalled();
});
});
it("passes blocker attention to the issue detail header status icon", async () => {
mockIssuesApi.get.mockResolvedValue(
createIssue({
status: "blocked",
blockerAttention: {
state: "covered",
reason: "active_child",
unresolvedBlockerCount: 1,
coveredBlockerCount: 1,
stalledBlockerCount: 0,
attentionBlockerCount: 0,
sampleBlockerIdentifier: "PAP-2",
sampleStalledBlockerIdentifier: null,
},
}),
);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
expect(
container.querySelector('[data-status-icon-state="covered"]')
?.textContent,
).toBe("blocked");
});
it.each([false, true])(
"refreshes a released pause and shows partial wake failure=%s inline",
async (wakeFailed) => {
const childIssue = createIssue({
id: "child-1",
parentId: "issue-1",
identifier: "PAP-2",
issueNumber: 2,
title: "Held child",
});
const activeHold = createPauseHold();
const releasedHold = createPauseHold({
status: "released",
releasedAt: new Date("2026-04-21T00:01:00.000Z"),
releasedByActorType: "user",
releasedByUserId: "user-1",
releaseReason: "Ready to continue",
updatedAt: new Date("2026-04-21T00:01:00.000Z"),
});
let activePauseHoldState: null | {
holdId: string;
rootIssueId: string;
issueId: string;
isRoot: boolean;
mode: "pause";
reason: string | null;
releasePolicy: {
strategy: "manual" | "after_active_runs_finish";
note?: string | null;
} | null;
} = {
holdId: "hold-1",
rootIssueId: "issue-1",
issueId: "issue-1",
isRoot: true,
mode: "pause",
reason: null,
releasePolicy: { strategy: "manual", note: "full_pause" },
};
mockIssuesApi.get.mockResolvedValue(createIssue());
mockIssuesApi.list.mockImplementation(
(_companyId, filters?: { descendantOf?: string }) =>
Promise.resolve(
filters?.descendantOf === "issue-1" ? [childIssue] : [],
),
);
mockIssuesApi.getTreeControlState.mockImplementation(() =>
Promise.resolve({ activePauseHold: activePauseHoldState }),
);
mockIssuesApi.listTreeHolds.mockResolvedValue([activeHold]);
mockIssuesApi.previewTreeControl.mockResolvedValue(createResumePreview());
mockAgentsApi.list.mockResolvedValue([createAgent()]);
mockIssuesApi.releaseTreeHold.mockImplementation(() => {
activePauseHoldState = null;
return Promise.resolve({
...releasedHold,
...(wakeFailed
? {
wakeFailures: [
{ issueId: "child-1", message: "Agent unavailable" },
],
}
: {}),
});
});
mockAuthApi.getSession.mockResolvedValue({
session: { userId: "user-1" },
user: { id: "user-1" },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
await waitForAssertion(() => {
expect(container.textContent).toContain("Subtree is paused.");
});
expect(container.querySelector('[data-testid="paused-composer-takeover"]')).toBeTruthy();
expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0].composerPause.scope).toBe("subtree");
const taskChatShell = container.querySelector<HTMLElement>(
"[data-task-chat-shell]",
);
expect(taskChatShell?.classList).toContain("gap-3");
expect(taskChatShell?.classList).not.toContain("gap-6");
const resumeButton = Array.from(
container.querySelectorAll("button"),
).find((button) => button.textContent?.trim() === "Resume subtree");
expect(resumeButton).toBeTruthy();
await act(async () => {
resumeButton!.click();
});
await flushReact();
const applyResumeButton = Array.from(container.querySelectorAll("button"))
.filter((button) => button.textContent?.trim() === "Resume subtree")
.at(-1);
expect(applyResumeButton).toBeTruthy();
expect(container.textContent).toContain("Wake affected agents (1)");
await act(async () => {
applyResumeButton!.click();
});
await flushReact();
await flushReact();
expect(mockIssuesApi.releaseTreeHold).toHaveBeenCalledWith(
"PAP-1",
"hold-1",
{
reason: null,
metadata: { wakeAgents: true },
},
);
expect(
mockIssuesApi.getTreeControlState.mock.calls.length,
).toBeGreaterThanOrEqual(2);
expect(mockPushToast).not.toHaveBeenCalledWith(
expect.objectContaining({
title: "Subtree resumed",
}),
);
await waitForAssertion(() => {
expect(container.textContent).not.toContain("Subtree is paused.");
});
if (wakeFailed)
expect(
container.querySelector('[role="alert"]')?.textContent,
).toContain("Pause released");
else expect(container.querySelector('[role="alert"]')).toBeNull();
},
);
it("pauses the subtree immediately without preview or confirmation", async () => {
mockIssuesApi.previewTreeControl.mockClear();
const childIssue = createIssue({
id: "child-1",
parentId: "issue-1",
identifier: "PAP-2",
issueNumber: 2,
title: "Paused child",
});
const pausePreview = createPausePreview();
const pauseHold = createPauseHold({
id: "pause-hold-1",
mode: "pause",
reason: null,
releasePolicy: { strategy: "manual", note: "full_pause" },
members: [],
});
mockIssuesApi.get.mockResolvedValue(createIssue());
mockIssuesApi.list.mockImplementation(
(_companyId, filters?: { descendantOf?: string }) =>
Promise.resolve(
filters?.descendantOf === "issue-1" ? [childIssue] : [],
),
);
mockIssuesApi.previewTreeControl.mockResolvedValue(pausePreview);
mockIssuesApi.createTreeHold.mockResolvedValue({
hold: pauseHold,
preview: pausePreview,
});
mockAuthApi.getSession.mockResolvedValue({
session: { userId: "user-1" },
user: { id: "user-1" },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
const moreButton = container.querySelector(
'button[aria-label="More task actions"]',
) as HTMLButtonElement | null;
expect(moreButton).toBeTruthy();
await act(async () => {
moreButton!.dispatchEvent(
new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
);
});
await flushReact();
const pauseMenuButton = Array.from(
container.querySelectorAll("button"),
).find((button) => button.textContent?.trim() === "Pause subtree");
expect(pauseMenuButton).toBeTruthy();
await act(async () => {
pauseMenuButton!.click();
});
await flushReact();
await flushReact();
expect(mockIssuesApi.previewTreeControl).not.toHaveBeenCalled();
expect(container.querySelector('[data-slot="dialog-content"]')).toBeNull();
expect(mockIssuesApi.createTreeHold).toHaveBeenCalledWith("PAP-1", {
mode: "pause",
reason: null,
releasePolicy: { strategy: "manual", note: "full_pause" },
});
});
it.each(["active-run", "composer"])(
"routes %s Stop and the menu through the same pause operation",
async (control) => {
const pausePreview = createPausePreview();
pausePreview.totals = {
...pausePreview.totals,
totalIssues: 1,
affectedIssues: 1,
skippedIssues: 0,
activeRuns: 1,
};
pausePreview.issues = [pausePreview.issues[0]!];
pausePreview.skippedIssues = [];
const pauseHold = createPauseHold({
id: "leaf-pause-hold-1",
mode: "pause",
reason: null,
releasePolicy: { strategy: "manual", note: "leaf_pause" },
members: [],
});
mockIssuesApi.get.mockResolvedValue(
createIssue({
status: "in_progress",
assigneeAgentId: "agent-1",
executionRunId: "run-active-1",
}),
);
mockIssuesApi.previewTreeControl.mockResolvedValue(pausePreview);
mockIssuesApi.createTreeHold.mockResolvedValue({
hold: pauseHold,
preview: pausePreview,
});
mockAgentsApi.list.mockResolvedValue([createAgent()]);
mockHeartbeatsApi.liveRunsForIssue.mockResolvedValue([
{
id: "run-active-1",
agentId: "agent-1",
status: "running",
runtimeMode: "legacy",
issueId: "issue-1",
adapterType: "process",
},
]);
mockAuthApi.getSession.mockResolvedValue({
session: { userId: "user-1" },
user: { id: "user-1" },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0]).toMatchObject({
stopRunLabel: "Pause work",
stoppingRunLabel: "Pausing...",
issueWorkMode: "standard",
});
const chatPauseButton = Array.from(container.querySelectorAll("button"))
.filter((button) => button.textContent?.trim() === "Pause work")
.at(-1);
expect(chatPauseButton).toBeTruthy();
await act(async () => {
if (control === "composer") {
const stop =
mockIssueChatThreadRender.mock.calls.at(-1)?.[0].onCancelRun;
expect(stop).toBeTypeOf("function");
await stop();
} else chatPauseButton!.click();
});
await flushReact();
expect(mockIssuesApi.createTreeHold).toHaveBeenCalledWith("PAP-1", {
mode: "pause",
reason: null,
releasePolicy: { strategy: "manual", note: "leaf_pause" },
metadata: { source: "issue_active_run_control", runId: "run-active-1" },
});
const moreButton = container.querySelector(
'button[aria-label="More task actions"]',
) as HTMLButtonElement | null;
expect(moreButton).toBeTruthy();
await act(async () => {
moreButton!.dispatchEvent(
new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
);
});
await flushReact();
const pauseMenuButton = Array.from(
container.querySelectorAll("button"),
).find((button) => button.textContent?.trim() === "Pause work");
expect(pauseMenuButton).toBeTruthy();
await act(async () => {
pauseMenuButton!.click();
});
await flushReact();
expect(mockIssuesApi.createTreeHold).toHaveBeenLastCalledWith("PAP-1", {
mode: "pause",
reason: null,
releasePolicy: { strategy: "manual", note: "leaf_pause" },
});
expect(mockPushToast).not.toHaveBeenCalled();
mockIssuesApi.createTreeHold.mockRejectedValueOnce(
new Error("Unable to pause. Try again."),
);
await act(async () => {
await mockIssueChatThreadRender.mock.calls
.at(-1)?.[0]
.onStopRun("run-active-1");
});
await flushReact();
expect(container.querySelector('[role="alert"]')?.textContent).toContain(
"Unable to pause. Try again.",
);
expect(mockPushToast).not.toHaveBeenCalled();
},
);
it("routes live-run finalization actions through run cancellation before issue status update", async () => {
mockIssuesApi.get.mockResolvedValue(
createIssue({
status: "in_progress",
assigneeAgentId: "agent-1",
executionRunId: "run-active-1",
}),
);
mockIssuesApi.update.mockImplementation((_id, data) =>
Promise.resolve(
createIssue({
status: data.status as Issue["status"],
assigneeAgentId: "agent-1",
}),
),
);
mockHeartbeatsApi.cancel.mockResolvedValue(undefined);
mockAgentsApi.list.mockResolvedValue([createAgent()]);
mockAuthApi.getSession.mockResolvedValue({
session: { userId: "user-1" },
user: { id: "user-1" },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
const stopAndDoneButton = Array.from(
container.querySelectorAll("button"),
).find((button) => button.textContent?.trim() === "Stop and done");
expect(stopAndDoneButton).toBeTruthy();
await act(async () => {
stopAndDoneButton!.click();
});
await flushReact();
expect(mockHeartbeatsApi.cancel).toHaveBeenCalledWith("run-active-1");
expect(mockIssuesApi.update).toHaveBeenCalledWith("PAP-1", {
status: "done",
});
expect(mockHeartbeatsApi.cancel.mock.invocationCallOrder[0]).toBeLessThan(
mockIssuesApi.update.mock.invocationCallOrder[0],
);
const stopAndCancelButton = Array.from(
container.querySelectorAll("button"),
).find((button) => button.textContent?.trim() === "Stop and cancel");
expect(stopAndCancelButton).toBeTruthy();
await act(async () => {
stopAndCancelButton!.click();
});
await flushReact();
expect(mockIssuesApi.update).toHaveBeenLastCalledWith("PAP-1", {
status: "cancelled",
});
expect(mockHeartbeatsApi.cancel).toHaveBeenCalledTimes(2);
expect(mockHeartbeatsApi.cancel.mock.invocationCallOrder[1]).toBeLessThan(
mockIssuesApi.update.mock.invocationCallOrder[1],
);
});
it("reports partial success when run finalization stops the run but task status update fails", async () => {
mockIssuesApi.get.mockResolvedValue(
createIssue({
status: "in_progress",
assigneeAgentId: "agent-1",
executionRunId: "run-active-1",
}),
);
mockIssuesApi.update.mockRejectedValue(new Error("Status write failed"));
mockHeartbeatsApi.cancel.mockResolvedValue(undefined);
mockAgentsApi.list.mockResolvedValue([createAgent()]);
mockAuthApi.getSession.mockResolvedValue({
session: { userId: "user-1" },
user: { id: "user-1" },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
const stopAndDoneButton = Array.from(
container.querySelectorAll("button"),
).find((button) => button.textContent?.trim() === "Stop and done");
expect(stopAndDoneButton).toBeTruthy();
await act(async () => {
stopAndDoneButton!.click();
});
await flushReact();
expect(mockHeartbeatsApi.cancel).toHaveBeenCalledWith("run-active-1");
expect(mockPushToast).toHaveBeenCalledWith(
expect.objectContaining({
title: "Run stopped; task update failed",
body: "Run was stopped, but updating the task failed: Status write failed",
tone: "error",
}),
);
});
it("passes planning work mode to the issue chat thread", async () => {
mockIssuesApi.get.mockResolvedValue(createIssue({ workMode: "planning" }));
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0]).toMatchObject({
issueWorkMode: "planning",
});
});
it("reports the selected continuation action after rejecting completion", async () => {
const pendingInteraction = {
id: "interaction-continue",
companyId: "company-1",
issueId: "issue-1",
kind: "request_confirmation",
title: "Review completion",
summary: null,
status: "pending",
continuationPolicy: "wake_assignee",
resolverPolicy: "human_only",
requestedResolverPolicy: "human_only",
effectiveResolverPolicy: "human_only",
resolverPolicyProvenance: "explicit",
effectiveResolverPolicySource: "requested",
legacyResolverPolicyAliases: {
requested: "board_only",
effective: "board_only",
},
createdByAgentId: "agent-1",
createdByUserId: null,
resolvedByAgentId: null,
resolvedByUserId: null,
createdAt: new Date("2026-09-06T12:00:00.000Z"),
updatedAt: new Date("2026-09-06T12:00:00.000Z"),
resolvedAt: null,
payload: {
version: 1,
prompt: "Is this task ready to complete?",
acceptLabel: "Approve completion",
rejectLabel: "Continue work",
},
result: null,
} satisfies RequestConfirmationInteraction;
const rejectedInteraction = {
...pendingInteraction,
status: "rejected",
resolvedAt: new Date("2026-09-06T12:01:00.000Z"),
result: { version: 1, outcome: "rejected", reason: "Run turn 2" },
} satisfies RequestConfirmationInteraction;
mockIssuesApi.get.mockResolvedValue(createIssue());
mockIssuesApi.rejectInteraction.mockResolvedValue(rejectedInteraction);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
const props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as {
onRejectInteraction?: (
interaction: RequestConfirmationInteraction,
reason?: string,
) => Promise<void>;
};
expect(props.onRejectInteraction).toBeTypeOf("function");
await act(async () => {
await props.onRejectInteraction?.(pendingInteraction, "Run turn 2");
});
expect(mockIssuesApi.rejectInteraction).toHaveBeenCalledWith(
"PAP-1",
pendingInteraction.id,
"Run turn 2",
);
expect(mockPushToast).toHaveBeenCalledWith({
title: "Selected “Continue work”",
tone: "success",
});
});
it("passes ask work mode to the issue chat thread", async () => {
mockIssuesApi.get.mockResolvedValue(createIssue({ workMode: "ask" }));
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0]).toMatchObject({
issueWorkMode: "ask",
});
});
it("falls back to execCommand when copying the task from an insecure context", async () => {
const clipboardWrite = vi.fn(async () => {
throw new Error("Clipboard API blocked");
});
const execCommand = vi.fn(() => true);
const originalClipboard = Object.getOwnPropertyDescriptor(
navigator,
"clipboard",
);
const originalExecCommand = Object.getOwnPropertyDescriptor(
document,
"execCommand",
);
const originalSecureContext = Object.getOwnPropertyDescriptor(
window,
"isSecureContext",
);
Object.defineProperty(window, "isSecureContext", {
configurable: true,
value: false,
});
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText: clipboardWrite },
});
Object.defineProperty(document, "execCommand", {
configurable: true,
value: execCommand,
});
mockIssuesApi.get.mockResolvedValue(
createIssue({
identifier: "PAP-1",
title: "Copy me",
description: "Task body",
}),
);
try {
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
const moreButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="More task actions"]',
);
await act(async () => moreButton!.click());
const copyButton = Array.from(
document.body.querySelectorAll<HTMLButtonElement>("button"),
).find((button) => button.textContent?.trim() === "Copy as markdown");
expect(copyButton).toBeTruthy();
await act(async () => {
copyButton!.click();
await Promise.resolve();
});
expect(clipboardWrite).not.toHaveBeenCalled();
expect(execCommand).toHaveBeenCalledWith("copy");
expect(mockPushToast).toHaveBeenCalledWith(
expect.objectContaining({
title: "Copied to clipboard",
tone: "success",
}),
);
} finally {
if (originalClipboard) {
Object.defineProperty(navigator, "clipboard", originalClipboard);
} else {
// @ts-expect-error test cleanup for optional browser API
delete navigator.clipboard;
}
if (originalExecCommand) {
Object.defineProperty(document, "execCommand", originalExecCommand);
} else {
// @ts-expect-error test cleanup for optional browser API
delete document.execCommand;
}
if (originalSecureContext) {
Object.defineProperty(window, "isSecureContext", originalSecureContext);
} else {
// @ts-expect-error test cleanup for optional browser API
delete window.isSecureContext;
}
}
});
it("renders the task chat thread as the default thread", async () => {
mockIssuesApi.get.mockResolvedValue(createIssue());
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
expect(
container.querySelector('[data-testid="task-chat-thread"]'),
).not.toBeNull();
expect(mockIssueChatThreadRender).toHaveBeenCalled();
});
it("renders the legacy issue chat thread when the classic task interface flag is on", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIssuePlanDecompositions: false,
enableExperimentalFileViewer: false,
enableExternalObjects: false,
enableStreamlinedUi: true,
enableClassicTaskInterface: true,
});
mockIssuesApi.get.mockResolvedValue(createIssue());
mockIssuesApi.listInteractions.mockResolvedValue([
{
id: "classic-question",
companyId: "company-1",
issueId: "issue-1",
kind: "ask_user_questions",
status: "answered",
sourceRunId: "legacy-run",
resolvedByUserId: "user-1",
resolvedAt: "2026-04-21T00:00:04.000Z",
createdAt: "2026-04-21T00:00:01.000Z",
updatedAt: "2026-04-21T00:00:04.000Z",
payload: {
version: 1,
questions: [
{
id: "runtime",
prompt: "Which runtime?",
selectionMode: "single",
options: [{ id: "node", label: "Node.js" }],
},
],
},
result: {
version: 1,
answers: [{ questionId: "runtime", optionIds: ["node"] }],
},
},
]);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
expect(
container.querySelector('[data-testid="issue-chat-thread"]'),
).not.toBeNull();
expect(
container.querySelector('[data-testid="task-chat-thread"]'),
).toBeNull();
expect(mockIssueChatThreadRender).toHaveBeenCalled();
const classicProps = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as {
comments?: Array<{ id: string }>;
};
expect(
classicProps.comments?.some(
(comment) => comment.id === "interaction-response:classic-question",
),
).toBe(false);
});
it("restores master's task chat thread when Streamlined UI is off", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIssuePlanDecompositions: false,
enableExperimentalFileViewer: false,
enableExternalObjects: false,
enableStreamlinedUi: false,
enableClassicTaskInterface: false,
});
mockIssuesApi.get.mockResolvedValue(createIssue());
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
expect(
container.querySelector('[data-testid="issue-chat-thread"]'),
).toBeNull();
expect(
container.querySelector('[data-testid="task-chat-thread"]'),
).not.toBeNull();
});
it("still honors Classic Task Interface when Streamlined UI is off", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIssuePlanDecompositions: false,
enableExperimentalFileViewer: false,
enableExternalObjects: false,
enableStreamlinedUi: false,
enableClassicTaskInterface: true,
});
mockIssuesApi.get.mockResolvedValue(createIssue());
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
expect(
container.querySelector('[data-testid="issue-chat-thread"]'),
).not.toBeNull();
expect(
container.querySelector('[data-testid="task-chat-thread"]'),
).toBeNull();
});
it("passes @task mention options to the thread by default", async () => {
const mentionPoolIssue = {
...createIssue(),
id: "issue-mention-1",
identifier: "PAP-9",
title: "Mentionable task",
};
mockIssuesApi.list.mockImplementation(
(_companyId: string, filters?: { sortField?: string }) =>
Promise.resolve(
filters?.sortField === "updated" ? [mentionPoolIssue] : [],
),
);
mockIssuesApi.get.mockResolvedValue(createIssue());
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
await waitForAssertion(() => {
expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0].mentions).toEqual(
expect.arrayContaining([
expect.objectContaining({ kind: "issue", issueIdentifier: "PAP-9" }),
]),
);
});
expect(mockIssuesApi.list).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({ sortField: "updated" }),
);
});
it("forwards composer work mode changes to the issues API", async () => {
const issue = createIssue();
mockIssuesApi.get.mockResolvedValue(issue);
mockIssuesApi.listAttachments.mockResolvedValue([
{
id: "attachment-1",
issueId: issue.id,
issueCommentId: null,
originalFilename: "planning-notes.txt",
contentPath: "/attachments/planning-notes.txt",
contentType: "text/plain",
byteSize: 4096,
uploadedByUserId: null,
uploadedAt: new Date("2026-04-21T00:02:00.000Z"),
},
]);
localStorage.setItem(
"paperclip:issue-comment-draft:issue-1",
"Draft follow-up message",
);
mockIssuesApi.update.mockResolvedValue(
createIssue({ workMode: "planning" }),
);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
const lastChatThreadProps =
mockIssueChatThreadRender.mock.calls.at(-1)?.[0];
expect(lastChatThreadProps?.issueWorkMode).toBe("standard");
expect(typeof lastChatThreadProps?.onWorkModeChange).toBe("function");
await act(async () => {
lastChatThreadProps?.onWorkModeChange?.("ask");
});
await flushReact();
expect(mockIssuesApi.update).toHaveBeenCalledWith(issue.identifier, {
workMode: "ask",
});
expect(localStorage.getItem("paperclip:issue-comment-draft:issue-1")).toBe(
"Draft follow-up message",
);
localStorage.removeItem("paperclip:issue-comment-draft:issue-1");
});
it("renders a quiet task pause notice and defaults leaf resume to wake the assignee", async () => {
const activeHold = createPauseHold();
const releasedHold = createPauseHold({
status: "released",
releasedAt: new Date("2026-04-21T00:01:00.000Z"),
releasedByActorType: "user",
releasedByUserId: "user-1",
releaseReason: "Ready to continue",
updatedAt: new Date("2026-04-21T00:01:00.000Z"),
});
mockIssuesApi.get.mockResolvedValue(
createIssue({
status: "in_review",
assigneeAgentId: "agent-1",
}),
);
mockIssuesApi.getTreeControlState.mockResolvedValue({
activePauseHold: {
holdId: "hold-1",
rootIssueId: "issue-1",
issueId: "issue-1",
isRoot: true,
mode: "pause",
reason: null,
releasePolicy: { strategy: "manual", note: "leaf_pause" },
},
});
mockIssuesApi.listTreeHolds.mockResolvedValue([activeHold]);
mockIssuesApi.previewTreeControl.mockResolvedValue(createResumePreview());
mockIssuesApi.releaseTreeHold.mockResolvedValue(releasedHold);
mockAgentsApi.list.mockResolvedValue([createAgent()]);
mockAuthApi.getSession.mockResolvedValue({
session: { userId: "user-1" },
user: { id: "user-1" },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
await waitForAssertion(() => {
expect(container.textContent).toContain("Task is paused.");
expect(container.textContent).toContain("in_review");
expect(container.textContent).not.toContain("Subtree is paused.");
});
const resumeButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "Resume task",
);
expect(resumeButton).toBeTruthy();
await act(async () => {
resumeButton!.click();
});
await flushReact();
await flushReact();
const wakeCheckbox = container.querySelector(
'input[type="checkbox"]',
) as HTMLInputElement | null;
expect(wakeCheckbox?.checked).toBe(true);
const applyResumeButton = Array.from(container.querySelectorAll("button"))
.filter((button) => button.textContent?.trim() === "Resume work")
.at(-1);
expect(applyResumeButton).toBeTruthy();
await act(async () => {
applyResumeButton!.click();
});
await flushReact();
expect(mockIssuesApi.releaseTreeHold).toHaveBeenCalledWith(
"PAP-1",
"hold-1",
{
reason: null,
metadata: { wakeAgents: true },
},
);
});
it("exposes restore subtree from the issue actions menu", async () => {
const childIssue = createIssue({
id: "child-1",
parentId: "issue-1",
identifier: "PAP-2",
issueNumber: 2,
title: "Cancelled child",
status: "cancelled",
assigneeAgentId: "agent-1",
});
const cancelHold = createPauseHold({
id: "cancel-hold-1",
mode: "cancel",
reason: "bad plan",
members: [],
});
const restorePreview = createRestorePreview();
const restoreHold = createPauseHold({
id: "restore-hold-1",
mode: "restore",
status: "released",
reason: null,
releaseReason: "Restore operation applied",
releasedAt: new Date("2026-04-21T00:02:00.000Z"),
members: [],
});
mockIssuesApi.get.mockResolvedValue(createIssue());
mockIssuesApi.list.mockImplementation(
(_companyId, filters?: { descendantOf?: string }) =>
Promise.resolve(
filters?.descendantOf === "issue-1" ? [childIssue] : [],
),
);
mockIssuesApi.listTreeHolds.mockImplementation(
(_issueId, filters?: { mode?: string }) =>
Promise.resolve(filters?.mode === "cancel" ? [cancelHold] : []),
);
mockIssuesApi.previewTreeControl.mockResolvedValue(restorePreview);
mockIssuesApi.createTreeHold.mockResolvedValue({
hold: restoreHold,
preview: restorePreview,
});
mockAuthApi.getSession.mockResolvedValue({
session: { userId: "user-1" },
user: { id: "user-1" },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
const moreButton = container.querySelector(
'button[aria-label="More task actions"]',
) as HTMLButtonElement | null;
expect(moreButton).toBeTruthy();
await act(async () => {
moreButton!.dispatchEvent(
new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
);
});
await flushReact();
const restoreMenuButton = Array.from(
container.querySelectorAll("button"),
).find((button) => button.textContent?.trim() === "Restore subtree...");
expect(restoreMenuButton).toBeTruthy();
await act(async () => {
restoreMenuButton!.click();
});
await flushReact();
await flushReact();
expect(mockIssuesApi.previewTreeControl).toHaveBeenCalledWith("PAP-1", {
mode: "restore",
releasePolicy: { strategy: "manual" },
});
expect(container.textContent).toContain("1 task will be restored.");
const restoreApplyButton = Array.from(
container.querySelectorAll("button"),
).find((button) => button.textContent?.trim() === "Restore 1 task");
expect(restoreApplyButton).toBeTruthy();
await act(async () => {
restoreApplyButton!.click();
});
await flushReact();
expect(mockIssuesApi.createTreeHold).toHaveBeenCalledWith("PAP-1", {
mode: "restore",
reason: null,
releasePolicy: { strategy: "manual" },
metadata: { wakeAgents: false },
});
});
it("confirms cancellation once without a reason, checkbox, or task inventory", async () => {
mockIssuesApi.createTreeHold.mockClear();
const childIssue = createIssue({
id: "child-1",
parentId: "issue-1",
identifier: "PAP-2",
issueNumber: 2,
title: "Cancellable child",
});
mockIssuesApi.get.mockResolvedValue(createIssue());
mockIssuesApi.list.mockImplementation(
(_companyId, filters?: { descendantOf?: string }) =>
Promise.resolve(
filters?.descendantOf === "issue-1" ? [childIssue] : [],
),
);
mockIssuesApi.previewTreeControl.mockResolvedValue(createCancelPreview(24));
mockAuthApi.getSession.mockResolvedValue({
session: { userId: "user-1" },
user: { id: "user-1" },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
const moreButton = container.querySelector(
'button[aria-label="More task actions"]',
)!;
await act(async () => {
moreButton.dispatchEvent(
new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
);
});
await flushReact();
const cancelMenuButton = Array.from(
container.querySelectorAll("button"),
).find((button) => button.textContent?.trim() === "Cancel subtree...");
expect(cancelMenuButton).toBeTruthy();
await act(async () => {
cancelMenuButton!.click();
});
await flushReact();
await flushReact();
expect(mockIssuesApi.previewTreeControl).toHaveBeenCalledWith("PAP-1", {
mode: "cancel",
releasePolicy: { strategy: "manual" },
});
const dialogContent = container.querySelector(
'[data-slot="dialog-content"]',
) as HTMLDivElement | null;
expect(dialogContent).toBeTruthy();
expect(dialogContent!.textContent).toContain("Cancel subtree?");
expect(dialogContent!.textContent).toContain("24 tasks will be cancelled.");
expect(dialogContent!.textContent).toContain("Keep tasks");
expect(
dialogContent!.querySelector('textarea, input[type="checkbox"]'),
).toBeNull();
expect(dialogContent!.textContent).not.toContain("Cancellable child");
expect(mockIssuesApi.createTreeHold).not.toHaveBeenCalled();
const cancelApplyButton = Array.from(
dialogContent!.querySelectorAll("button"),
).find((button) => button.textContent?.trim() === "Cancel 24 tasks")!;
expect(cancelApplyButton.disabled).toBe(false);
mockIssuesApi.createTreeHold.mockResolvedValue({
hold: { ...createPauseHold(), mode: "cancel" },
preview: createCancelPreview(24),
});
await act(async () => {
cancelApplyButton.click();
});
await flushReact();
expect(mockIssuesApi.createTreeHold).toHaveBeenCalledWith("PAP-1", {
mode: "cancel",
reason: null,
releasePolicy: { strategy: "manual" },
});
});
it("keeps the authoritative Paperclip queue mounted after handoff promotion", async () => {
mockIssuesApi.get.mockResolvedValue(
createIssue({
status: "in_progress",
assigneeAgentId: "agent-1",
executionRunId: "run-promoted-1",
}),
);
mockAgentsApi.list.mockResolvedValue([
createAgent({ adapterType: "paperclip_runner" }),
]);
mockIssuesApi.getQueuedComments.mockResolvedValue(
createQueuedCommentQueue({
state: "queued",
targetRunId: null,
}),
);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await waitForAssertion(() => {
const props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as
| {
queuedCommentQueue?: IssueQueuedCommentQueue | null;
}
| undefined;
expect(mockIssuesApi.getQueuedComments).toHaveBeenCalledWith("issue-1");
expect(props?.queuedCommentQueue).toMatchObject({
queueId: "wake-queue-1",
state: "queued",
targetRunId: null,
});
});
});
it.each(DIRECT_ADAPTER_TYPES)(
"loads only the shared queue projection for an active %s run after reassignment",
async (adapterType) => {
mockIssuesApi.get.mockResolvedValue(
createIssue({
status: "in_progress",
assigneeAgentId: "agent-1",
executionRunId: "run-direct-1",
}),
);
mockAgentsApi.list.mockResolvedValue([
createAgent({ adapterType: "paperclip_runner" }),
]);
const directRun = {
id: "run-direct-1",
runtimeMode: "legacy" as const,
status: "running",
invocationSource: "issue",
triggerDetail: null,
contextCommentId: null,
contextWakeCommentId: null,
startedAt: "2026-04-21T00:00:00.000Z",
finishedAt: null,
createdAt: "2026-04-21T00:00:00.000Z",
agentId: "agent-1",
agentName: "Coder",
adapterType,
issueId: "issue-1",
};
mockHeartbeatsApi.activeRunForIssue.mockResolvedValue(directRun);
mockHeartbeatsApi.liveRunsForIssue.mockResolvedValue([directRun]);
mockIssuesApi.getQueuedComments.mockClear();
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
expect(mockIssuesApi.getQueuedComments).toHaveBeenCalledWith("issue-1");
const props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as {
queuedCommentQueue?: IssueQueuedCommentQueue | null;
runFinalizationActions?: readonly { id: string; label: string }[];
};
expect(props.queuedCommentQueue).toBeNull();
expect(props.runFinalizationActions).toEqual([
expect.objectContaining({ id: "cancel", label: "Stop and cancel" }),
expect.objectContaining({ id: "done", label: "Stop and done" }),
]);
},
);
it("promotes a steered message immediately while its durable timeline position refreshes", async () => {
const queue = createQueuedCommentQueue();
const steeredQueue = createQueuedCommentQueue({
queueId: null,
state: null,
targetRunId: null,
entries: [],
revision: "queue-revision-2",
});
mockIssuesApi.get.mockResolvedValue(
createIssue({
status: "in_progress",
assigneeAgentId: "agent-1",
executionRunId: "run-active-1",
}),
);
mockAgentsApi.list.mockResolvedValue([
createAgent({ adapterType: "paperclip_runner" }),
]);
mockIssuesApi.listComments.mockResolvedValue([queue.entries[0].comment]);
mockIssuesApi.getQueuedComments.mockResolvedValue(queue);
mockHeartbeatsApi.activeRunForIssue.mockResolvedValue({
id: "run-active-1",
runtimeMode: "native",
status: "running",
invocationSource: "issue",
triggerDetail: null,
contextCommentId: null,
contextWakeCommentId: null,
startedAt: "2026-04-21T00:00:00.000Z",
finishedAt: null,
createdAt: "2026-04-21T00:00:00.000Z",
agentId: "agent-1",
agentName: "Runner",
adapterType: "paperclip_runner",
issueId: "issue-1",
});
mockIssuesApi.steerQueuedComment.mockResolvedValue(steeredQueue);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
let steer!: (commentId: string, revision: string) => Promise<void>;
await waitForAssertion(() => {
const props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as {
onSteerQueuedComment?: typeof steer;
queuedCommentQueue?: IssueQueuedCommentQueue | null;
};
expect(props.queuedCommentQueue?.entries).toHaveLength(1);
expect(props.queuedCommentQueue?.queueId).toBe("wake-queue-1");
expect(props.onSteerQueuedComment).toBeTypeOf("function");
steer = props.onSteerQueuedComment!;
});
let releaseActivity!: (value: unknown[]) => void;
const activityRefresh = new Promise<unknown[]>((resolve) => {
releaseActivity = resolve;
});
mockActivityApi.forIssue.mockReturnValue(activityRefresh);
let steeringPromise!: Promise<void>;
await act(async () => {
steeringPromise = steer("queued-comment-1", queue.revision);
await Promise.resolve();
});
await waitForAssertion(() => {
expect(mockIssuesApi.steerQueuedComment).toHaveBeenCalled();
expect(mockActivityApi.forIssue.mock.calls.length).toBeGreaterThan(1);
});
const whileRefreshing = mockIssueChatThreadRender.mock.calls.at(
-1,
)?.[0] as {
comments?: Array<{
id: string;
steeredIntoRunId?: string | null;
conversationAnchorAt?: Date | string | null;
}>;
queuedCommentQueue?: IssueQueuedCommentQueue | null;
};
expect(whileRefreshing.queuedCommentQueue).toBeNull();
expect(
whileRefreshing.comments?.find(
(comment) => comment.id === "queued-comment-1",
),
).toMatchObject({
steeredIntoRunId: "run-active-1",
conversationAnchorAt: expect.any(String),
});
releaseActivity([
{
id: "activity-steer-1",
companyId: "company-1",
issueId: "issue-1",
action: "issue.queued_comment_steered",
createdAt: new Date("2026-04-21T00:00:06.000Z"),
details: {
commentId: "queued-comment-1",
targetRunId: "run-active-1",
duplicate: false,
},
},
]);
await act(async () => {
await steeringPromise;
});
await waitForAssertion(() => {
const props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as {
comments?: Array<{
id: string;
steeredIntoRunId?: string | null;
conversationAnchorAt?: Date | string | null;
}>;
queuedCommentQueue?: IssueQueuedCommentQueue | null;
};
expect(props.queuedCommentQueue).toBeNull();
expect(
props.comments?.find((comment) => comment.id === "queued-comment-1"),
).toMatchObject({
steeredIntoRunId: "run-active-1",
conversationAnchorAt: "2026-04-21T00:00:06.000Z",
});
expect(
props.comments?.find((comment) => comment.id === "queued-comment-1"),
).not.toMatchObject({ clientStatus: "queued", queueState: "queued" });
});
});
it("keeps an unacknowledged queue visible while withholding server controls", async () => {
mockIssuesApi.get.mockResolvedValue(
createIssue({
status: "in_progress",
assigneeAgentId: "agent-1",
}),
);
mockAgentsApi.list.mockResolvedValue([
createAgent({ adapterType: "paperclip_runner" }),
]);
mockIssuesApi.getQueuedComments.mockResolvedValue(
createQueuedCommentQueue({
queueId: null,
state: null,
}),
);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await waitForAssertion(() => {
const props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as
| {
queuedCommentQueue?: IssueQueuedCommentQueue | null;
}
| undefined;
expect(mockIssuesApi.getQueuedComments).toHaveBeenCalled();
expect(props?.queuedCommentQueue).toMatchObject({
queueId: null,
entries: [
expect.objectContaining({
comment: expect.objectContaining({ id: "queued-comment-1" }),
}),
],
});
});
});
it("keeps a discarded queued comment out of the thread when the queue becomes empty", async () => {
const queue = createQueuedCommentQueue();
const emptyQueue = createQueuedCommentQueue({
queueId: null,
state: null,
targetRunId: null,
entries: [],
revision: "queue-revision-2",
});
mockIssuesApi.get.mockResolvedValue(
createIssue({
status: "in_progress",
assigneeAgentId: "agent-1",
executionRunId: "run-active-1",
}),
);
mockAgentsApi.list.mockResolvedValue([
createAgent({ adapterType: "paperclip_runner" }),
]);
// Keep returning the pre-discard page to exercise the local projection
// across the queueId -> null transition, as can happen during refetch.
mockIssuesApi.listComments.mockResolvedValue([queue.entries[0].comment]);
mockIssuesApi.getQueuedComments.mockResolvedValue(queue);
mockIssuesApi.discardQueuedComment.mockResolvedValue(emptyQueue);
mockHeartbeatsApi.activeRunForIssue.mockResolvedValue({
id: "run-active-1",
runtimeMode: "native",
status: "running",
invocationSource: "issue",
triggerDetail: null,
contextCommentId: null,
contextWakeCommentId: null,
startedAt: "2026-04-21T00:00:00.000Z",
finishedAt: null,
createdAt: "2026-04-21T00:00:00.000Z",
agentId: "agent-1",
agentName: "Runner",
adapterType: "paperclip_runner",
issueId: "issue-1",
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
let discard!: (commentId: string, revision: string) => Promise<void>;
await waitForAssertion(() => {
const props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as {
comments?: Array<{ id: string }>;
onDiscardQueuedComment?: typeof discard;
queuedCommentQueue?: IssueQueuedCommentQueue | null;
};
expect(
props.comments?.some((comment) => comment.id === "queued-comment-1"),
).toBe(true);
expect(props.queuedCommentQueue?.queueId).toBe("wake-queue-1");
expect(props.queuedCommentQueue?.entries).toHaveLength(1);
discard = props.onDiscardQueuedComment!;
});
await act(async () => {
await discard("queued-comment-1", queue.revision);
});
await waitForAssertion(() => {
const props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as {
comments?: Array<{ id: string }>;
queuedCommentQueue?: IssueQueuedCommentQueue | null;
};
expect(props.queuedCommentQueue).toBeNull();
expect(
props.comments?.some((comment) => comment.id === "queued-comment-1"),
).toBe(false);
});
});
it("uses queue identity for discard and surfaces a persistent too-late error", async () => {
const queue = createQueuedCommentQueue();
mockIssuesApi.get.mockResolvedValue(
createIssue({
status: "in_progress",
assigneeAgentId: "agent-1",
executionRunId: "run-active-1",
}),
);
mockAgentsApi.list.mockResolvedValue([
createAgent({ adapterType: "paperclip_runner" }),
]);
mockIssuesApi.getQueuedComments.mockResolvedValue(queue);
mockIssuesApi.discardQueuedComment.mockRejectedValue(
new ApiError("The queued message is already being dispatched", 409, {
details: { code: "queued_comment_already_dispatching" },
}),
);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
let discard!: (commentId: string, revision: string) => Promise<void>;
await waitForAssertion(() => {
const props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as
| {
onDiscardQueuedComment?: typeof discard;
queuedCommentQueue?: IssueQueuedCommentQueue | null;
}
| undefined;
expect(props?.onDiscardQueuedComment).toBeTypeOf("function");
expect(props?.queuedCommentQueue?.queueId).toBe("wake-queue-1");
discard = props!.onDiscardQueuedComment!;
});
await expect(discard("queued-comment-1", queue.revision)).rejects.toThrow(
"already being dispatched",
);
expect(mockIssuesApi.discardQueuedComment).toHaveBeenCalledWith(
"issue-1",
"queued-comment-1",
{
queueId: "wake-queue-1",
revision: "queue-revision-1",
},
);
expect(mockPushToast).toHaveBeenCalledWith(
expect.objectContaining({
title: "Message is already being sent",
tone: "error",
ttlMs: 15_000,
}),
);
});
});
describe("canBoardResolveRecoveryAction", () => {
it("falls back to companyIds when memberships are not populated", () => {
expect(
canBoardResolveRecoveryAction("company-1", {
companyIds: ["company-1"],
memberships: [],
isInstanceAdmin: false,
source: "session",
keyId: null,
user: null,
userId: "user-1",
}),
).toBe(true);
});
it("uses populated memberships as the authoritative board access source", () => {
expect(
canBoardResolveRecoveryAction("company-1", {
companyIds: ["company-1"],
memberships: [
{
companyId: "company-1",
membershipRole: "viewer",
status: "active",
},
],
isInstanceAdmin: false,
source: "session",
keyId: null,
user: null,
userId: "user-1",
}),
).toBe(false);
});
});
describe("canBoardManageRuntime", () => {
it("falls back to companyIds when memberships are not populated", () => {
expect(
canBoardManageRuntime("company-1", {
companyIds: ["company-1"],
memberships: [],
isInstanceAdmin: false,
source: "session",
keyId: null,
user: null,
userId: "user-1",
}),
).toBe(true);
});
it("denies viewers the runtime-manage-gated break-glass affordance", () => {
expect(
canBoardManageRuntime("company-1", {
companyIds: ["company-1"],
memberships: [
{
companyId: "company-1",
membershipRole: "viewer",
status: "active",
},
],
isInstanceAdmin: false,
source: "session",
keyId: null,
user: null,
userId: "user-1",
}),
).toBe(false);
});
it("allows non-viewer active members (mirrors the backend runtime:manage member gate)", () => {
expect(
canBoardManageRuntime("company-1", {
companyIds: ["company-1"],
memberships: [
{
companyId: "company-1",
membershipRole: "operator",
status: "active",
},
],
isInstanceAdmin: false,
source: "session",
keyId: null,
user: null,
userId: "user-1",
}),
).toBe(true);
});
});
describe("readRecoveryReconcileWorkspaceId", () => {
const makeAction = (
evidence: Record<string, unknown>,
kind = "workspace_validation",
) =>
({ kind, evidence }) as unknown as Parameters<
typeof readRecoveryReconcileWorkspaceId
>[0];
it("returns null when the action is missing", () => {
expect(readRecoveryReconcileWorkspaceId(null)).toBeNull();
expect(readRecoveryReconcileWorkspaceId(undefined)).toBeNull();
});
it("returns null for non-workspace_validation actions even with a workspace id in evidence", () => {
expect(
readRecoveryReconcileWorkspaceId(
makeAction(
{ workspaceValidation: { persistedExecutionWorkspaceId: "ws-1" } },
"stranded_assigned_issue",
),
),
).toBeNull();
});
it("prefers persistedExecutionWorkspaceId (git_worktree_branch_incoherence shape)", () => {
expect(
readRecoveryReconcileWorkspaceId(
makeAction({
workspaceValidation: {
reason: "git_worktree_branch_incoherence",
persistedExecutionWorkspaceId: "ws-diverged",
executionWorkspaceId: "ws-other",
},
}),
),
).toBe("ws-diverged");
});
it("falls back to executionWorkspaceId (git_worktree_not_reusable shape)", () => {
expect(
readRecoveryReconcileWorkspaceId(
makeAction({
workspaceValidation: {
reason: "git_worktree_not_reusable",
executionWorkspaceId: "ws-not-reusable",
},
}),
),
).toBe("ws-not-reusable");
});
it("returns null when the evidence carries no workspace reference (so the caller falls back to the page-level id)", () => {
expect(readRecoveryReconcileWorkspaceId(makeAction({}))).toBeNull();
expect(
readRecoveryReconcileWorkspaceId(
makeAction({
workspaceValidation: { reason: "git_worktree_branch_incoherence" },
}),
),
).toBeNull();
});
it("ignores non-string / empty workspace ids", () => {
expect(
readRecoveryReconcileWorkspaceId(
makeAction({
workspaceValidation: { persistedExecutionWorkspaceId: "" },
}),
),
).toBeNull();
expect(
readRecoveryReconcileWorkspaceId(
makeAction({
workspaceValidation: { persistedExecutionWorkspaceId: 42 },
}),
),
).toBeNull();
});
});
describe("shouldScrollIssueDetailToTopOnNavigation", () => {
it("does not scroll when only URL search params changed for the same issue", () => {
expect(
shouldScrollIssueDetailToTopOnNavigation({
previousIssueId: "PAP-10306",
nextIssueId: "PAP-10306",
navigationType: NavigationType.Push,
}),
).toBe(false);
});
it("scrolls on forward navigation to a different issue", () => {
expect(
shouldScrollIssueDetailToTopOnNavigation({
previousIssueId: "PAP-1",
nextIssueId: "PAP-2",
navigationType: NavigationType.Push,
}),
).toBe(true);
});
it("does not scroll on browser back or forward restoration", () => {
expect(
shouldScrollIssueDetailToTopOnNavigation({
previousIssueId: "PAP-1",
nextIssueId: "PAP-2",
navigationType: NavigationType.Pop,
}),
).toBe(false);
});
});