import { AgentAvatar } from "../components/AgentAvatar"; import { TaskChatPausedTakeover, type TaskComposerPause } from "../components/task-chat/TaskChatPausedTakeover"; // @vitest-environment jsdom import { DispositionRecoveryNotice, type DispositionRecoverySnapshot } from "../components/DispositionRecoveryNotice"; import { RichWorkProductCard } from "../components/task-chat/RichWorkProductCard"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { Agent, Issue, IssueAttachment, IssueComment, IssueRecentComments, 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, TaskDetailSurface, readRecoveryReconcileWorkspaceId, shouldScrollIssueDetailToTopOnNavigation, } from "./IssueDetail"; import { queryKeys } from "../lib/queryKeys"; import { armIssueDetailInboxQuickArchive, createIssueDetailLocationState, } from "../lib/issueDetailBreadcrumb"; import { getRecentTasksStorageKey, readRecentTasks } from "../lib/recent-tasks"; import { getLastProjectId, trackRecentProject } from "../lib/recent-projects"; import { ApiError } from "../api/client"; import type { issuesApi } from "../api/issues"; const mockGrokBotApi = vi.hoisted(() => ({ stop: vi.fn().mockResolvedValue({ stopped: true }) })); vi.mock("../api/grokBot", () => ({ grokBotApi: mockGrokBotApi })); const mockIssuesApi = vi.hoisted(() => ({ get: vi.fn(), getCore: vi.fn(), recentComments: vi.fn(), list: vi.fn(), listAll: vi.fn(), listAcceptedPlanDecompositions: vi.fn(), listComments: vi.fn(), listAttachments: vi.fn(), listWorkProducts: vi.fn(), checkMonitorNow: 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(), deleteComment: 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(), recentForIssue: vi.fn(), inputFactsForIssue: vi.fn(), runsForIssue: vi.fn(), recentRunsForIssue: vi.fn(), })); const mockHeartbeatsApi = vi.hoisted(() => ({ get: vi.fn(), 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(() => ({ key: "task-entry", 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", async (importOriginal) => { const actual = await importOriginal(); // Keep composed API operations real while replacing their request methods. // This also exercises the current-revision check used by both queue surfaces. return { ...actual, issuesApi: Object.assign(actual.issuesApi, mockIssuesApi) }; }); vi.mock("../api/email", () => ({ emailApi: { thread: vi.fn().mockResolvedValue(null) }, })); 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) => ( {children} ), 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; }) =>
{value || placeholder}
, })); 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; onInterruptQueued?: (runId: string) => Promise; onStopRun?: (runId: string) => Promise; stopRunLabel?: string; stoppingRunLabel?: string; runFinalizationActions?: readonly { id: string; label: string; onSelect: (runId: string) => Promise | void; }[]; composerPause?: TaskComposerPause | null; footer?: ReactNode; }) => { mockIssueChatThreadRender(props); return (
Chat thread {props.onStopRun ? ( ) : null} {props.runFinalizationActions?.map((action) => ( ))} {props.composerPause ? : null} {props.footer}
); }, })); // 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: { comments?: Array<{ metadata?: { recovery?: DispositionRecoverySnapshot } | null }>; workProducts?: IssueWorkProduct[]; threadHeader?: ReactNode; onStopRun?: (runId: string) => Promise; stopRunLabel?: string; onTryAgainNoLiveExecutionPath?: () => Promise | void; onRejectInteraction?: ( interaction: RequestConfirmationInteraction, reason?: string, ) => Promise; runFinalizationActions?: readonly { id: string; label: string; onSelect: (runId: string) => Promise | void; }[]; composerPause?: TaskComposerPause | null; footer?: ReactNode; }) => { mockIssueChatThreadRender(props); return (
{props.threadHeader} Task chat thread {props.comments?.map((comment, index) => comment.metadata?.recovery ? : null)} {props.workProducts?.map((workProduct) => ( ))} {props.onStopRun ? ( ) : null} {props.onTryAgainNoLiveExecutionPath ? ( ) : null} {props.runFinalizationActions?.map((action) => ( ))} {props.composerPause ? : null} {props.footer}
); }, })); vi.mock("../components/IssueDocumentsSection", () => ({ IssueDocumentsSection: () =>
Documents
, })); vi.mock("../components/MarkdownBody", () => ({ MarkdownBody: ({ children }: { children?: ReactNode }) => (
{children}
), })); vi.mock("../components/IssuesList", () => ({ IssuesList: (props: { issueBadgeById?: Map }) => { mockIssuesListRender(props); return (
Sub-issues {Array.from(props.issueBadgeById?.entries() ?? []).map( ([issueId, label]) => ( {issueId}:{label} ), )}
); }, })); vi.mock("../components/IssueProperties", () => ({ IssueProperties: (props: unknown) => { mockIssuePropertiesRender(props); return
Properties
; }, })); vi.mock("../components/task-side-panel", () => ({ TaskSidePanel: (props: unknown) => { mockTaskSidePanelRender(props); return
Task side panel
; }, })); vi.mock("../components/IssueRunLedger", () => ({ IssueRunLedger: () =>
Runs
, })); vi.mock("../components/IssueWorkspaceCard", () => ({ IssueWorkspaceCard: (props: { onBrowseFiles?: () => void; onOpenFileByPath?: () => void; }) => { mockIssueWorkspaceCardRender(props); return
Workspace
; }, })); 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 ? ( ) : ( {status} ), })); vi.mock("../components/PriorityIcon", () => ({ PriorityIcon: ({ priority, onChange, }: { priority: string; onChange?: (priority: string) => void; }) => onChange ? ( ) : ( {priority} ), })); vi.mock("../components/ApprovalCard", () => ({ ApprovalCard: () =>
Approval
, })); vi.mock("../components/Identity", () => ({ deriveInitials: (name: string) => name.slice(0, 2), Identity: ({ name, shape }: { name: string; shape?: string }) => ( {name} ), })); vi.mock("@/components/ui/button", () => ({ buttonVariants: () => "", Button: ({ children, disabled, onClick, type = "button", variant: _variant, size: _size, asChild: _asChild, ...props }: ButtonHTMLAttributes & { variant?: string; size?: string; asChild?: boolean; }) => ( ), })); vi.mock("@/components/ui/separator", () => ({ Separator: () =>
, })); vi.mock("@/components/ui/popover", () => ({ Popover: ({ children }: { children?: ReactNode }) => <>{children}, PopoverTrigger: ({ children }: { children?: ReactNode }) => <>{children}, PopoverContent: ({ children }: { children?: ReactNode }) => (
{children}
), })); vi.mock("@/components/ui/dialog", () => ({ Dialog: ({ children, open }: { children?: ReactNode; open?: boolean }) => open ?
{children}
: null, DialogContent: ({ children, className, }: { children?: ReactNode; className?: string; }) => (
{children}
), DialogDescription: ({ children, className, }: { children?: ReactNode; className?: string; }) =>

{children}

, DialogFooter: ({ children, className, }: { children?: ReactNode; className?: string; }) =>
{children}
, DialogHeader: ({ children, className, }: { children?: ReactNode; className?: string; }) =>
{children}
, DialogTitle: ({ children, className, }: { children?: ReactNode; className?: string; }) =>

{children}

, })); vi.mock("@/components/ui/sheet", () => ({ Sheet: ({ children, open }: { children?: ReactNode; open?: boolean }) => open ?
{children}
: null, SheetContent: ({ children, className, "data-testid": testId, }: { children?: ReactNode; className?: string; "data-testid"?: string; }) => (
{children}
), SheetHeader: ({ children }: { children?: ReactNode }) => (
{children}
), SheetDescription: ({ children }: { children?: ReactNode }) => (

{children}

), SheetTitle: ({ children }: { children?: ReactNode }) =>

{children}

, })); vi.mock("@/components/ui/scroll-area", () => ({ ScrollArea: ({ children }: { children?: ReactNode }) =>
{children}
, })); vi.mock("@/components/ui/skeleton", () => ({ Skeleton: () =>
, })); vi.mock("@/components/ui/tabs", () => ({ Tabs: ({ children }: { children?: ReactNode }) =>
{children}
, TabsContent: ({ children, className, "data-testid": testId, }: { children?: ReactNode; className?: string; "data-testid"?: string; }) => (
{children}
), TabsList: ({ children }: { children?: ReactNode }) =>
{children}
, TabsTrigger: ({ children }: { children?: ReactNode }) => ( ), })); vi.mock("@/components/ui/textarea", () => ({ Textarea: (props: React.TextareaHTMLAttributes) => (