6069 lines
182 KiB
TypeScript
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);
|
|
});
|
|
});
|