mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Task conversations keep follow-up messages pending while an agent works. > - The screen combines optimistic sends, queue snapshots, and run input records. > - A late send response can restore a local queue flag after a run takes the message. > - A stale queue snapshot can then keep the same message in pending. > - This pull request matches the input record to its source queue and clears stale pending flags. > - The benefit is that consumed messages leave pending while unsent messages stay visible. ## Linked Issues or Issue Description **What happened?** A follow-up can remain in the task composer's pending row after a run takes it as input. Local queue state can outlive the send request. The old projection cleared that state for steering, but not for ordinary run-start input. A stale queue read can also retain the consumed entry. **Expected behavior** A message that a started run takes as input appears once in history and leaves pending. Other pending messages remain available. A message assigned to a run that has not started remains pending. **Steps to reproduce** 1. Send a follow-up while a task has a live run. 2. Let a started run record that message as input before or after the send response arrives. 3. Let a queue read still include the consumed message. 4. Observe the message remain pending on the base revision. **Paperclip version or commit** Reproduced against master at `33d24e48ad` with controlled request ordering. **Deployment mode** Source checkout. Browser verification used the production task screen with a simulated native runner and delayed API responses. It did not run a live model or change a deployed instance. Related: [PR #15030](https://github.com/paperclipai/paperclip/pull/15030) addresses acknowledgement matching in the runner console. This change addresses the task-detail composer and does not modify that console. ## What Changed - Clear local pending flags when a started run records the comment as input. - Use live-run input IDs while the run-history query catches up. - Match stale queue entries by their primary or coalesced source wake ID, even when the snapshot targets the previous run or has no target. - Retain coalesced source queue IDs in run context and include receipt IDs in compact task run reads. - Keep unstarted run inputs and authoritative retries pending. Cover send ordering, same-run and successor delivery, queued snapshots, coalesced inputs, and retry consumption. - Add the delivery-race checks to the steering feature map. ## Verification - Before the fix, the first four request-order regressions failed with the consumed message still in pending. Coverage now includes 16 combinations of receipt source, send-response order, and stale snapshot kind. - Focused UI checks: 5 files and 233 tests passed. These cover task detail, queue projection, optimistic comments, queued-message controls, and the task-chat adapter. - `pnpm -r typecheck` passed. - Server receipt checks: 5 files and 139 tests passed with `PAPERCLIP_TEST_POSTGRES_TEMPLATE=0`. These exercise real database reads, coalesced heartbeat input, and polling routes. The default cached-template path timed out during setup on this host; the fresh-cluster path passed without longer timeouts. - Token gates and `node scripts/check-maps.mjs` passed. - Browser: sent a follow-up with a delayed response in the production task screen. The consumed message moved into history. A second unsent follow-up stayed queued. Reload preserved both states. The API and runner were simulated. - `pnpm build` passed. - The full local repository test run was started, then stopped to isolate database setup timeouts. All 55 GitHub CI checks passed on `4ec6820cf1f89880b801fd5f938d72f54591c5e2`, including the full test matrix. One serialized server shard passed on its single rerun after a 15-second route-test timeout; the same 97-test route suite also passed locally. - Greptile reviewed the final head at 5/5. All review threads are resolved. ## Risks - Queue display depends on matching comment IDs, source queue IDs, and a recorded start time. Rows without start evidence stay pending. Newer queues remain visible until their own input receipt arrives. - Run context gains additive receipt metadata. No schema migration is required. Dispatch order, stored queue state, company access, and steering authority stay unchanged. - Browser evidence covers controlled response ordering. It does not establish deployment or live-provider behavior. ## Model Used - OpenAI Codex, GPT-6, with reasoning, tool use, and code execution. The exact serving model ID and context-window limit are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
6508 lines
218 KiB
TypeScript
6508 lines
218 KiB
TypeScript
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,
|
||
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(),
|
||
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(),
|
||
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(() => ({
|
||
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<typeof import("../api/issues")>();
|
||
// 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<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: {
|
||
comments?: Array<{ metadata?: { recovery?: DispositionRecoverySnapshot } | null }>;
|
||
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.comments?.map((comment, index) => comment.metadata?.recovery ? <DispositionRecoveryNotice key={index} snapshot={comment.metadata.recovery} /> : null)}
|
||
{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", () => ({
|
||
deriveInitials: (name: string) => name.slice(0, 2),
|
||
Identity: ({ name, shape }: { name: string; shape?: string }) => (
|
||
<span data-shape={shape ?? "circle"}>{name}</span>
|
||
),
|
||
}));
|
||
|
||
vi.mock("@/components/ui/button", () => ({
|
||
buttonVariants: () => "",
|
||
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;
|
||
let reject!: (error: Error) => void;
|
||
const promise = new Promise<T>((innerResolve, innerReject) => {
|
||
resolve = innerResolve;
|
||
reject = innerReject;
|
||
});
|
||
return { promise, resolve, reject };
|
||
}
|
||
|
||
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>;
|
||
|
||
function renderIssueView(children: ReactNode = <IssueDetail />) {
|
||
root.render(
|
||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>,
|
||
);
|
||
}
|
||
|
||
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.checkMonitorNow.mockReset();
|
||
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({
|
||
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";
|
||
});
|
||
|
||
it("clears only the monitor after confirmation and refreshes the task", async () => {
|
||
const nextCheckAt = new Date(Date.now() + 60_000).toISOString();
|
||
const preservedPolicy = { mode: "normal", commentRequired: false, stages: [{ type: "review", approvalsNeeded: 1, participants: [{ type: "user", userId: "reviewer-1" }] }], maxReviewRounds: 4, authorizationPolicy: { assignmentPolicy: { mode: "protected" } } };
|
||
const policy = { ...preservedPolicy, monitor: { nextCheckAt, scheduledBy: "board", notes: "Check deployment" } } as Issue["executionPolicy"];
|
||
const monitored = createIssue({ status: "in_progress", executionPolicy: policy, executionState: { monitor: { status: "scheduled", nextCheckAt, attemptCount: 1 } } as Issue["executionState"] });
|
||
const cleared = createIssue({ status: "in_progress", executionPolicy: preservedPolicy as Issue["executionPolicy"] });
|
||
mockIssuesApi.get.mockResolvedValue(monitored);
|
||
mockIssuesApi.update.mockImplementation(async () => {
|
||
mockIssuesApi.get.mockResolvedValue(cleared);
|
||
return cleared;
|
||
});
|
||
await act(async () => renderIssueView());
|
||
await waitForAssertion(() => expect(container.querySelector('[aria-label="Cancel monitor"]')).not.toBeNull());
|
||
await act(async () => (container.querySelector('[aria-label="Cancel monitor"]') as HTMLButtonElement).click());
|
||
expect(mockIssuesApi.update).not.toHaveBeenCalled();
|
||
await act(async () => Array.from(document.querySelectorAll("button")).find(b => b.textContent === "Cancel monitor")!.click());
|
||
await waitForAssertion(() => expect(mockIssuesApi.update).toHaveBeenCalledWith(monitored.id, { expectedExecutionPolicy: policy, executionPolicy: preservedPolicy }));
|
||
await waitForAssertion(() => expect(container.querySelector('[aria-label="Cancel monitor"]')).toBeNull());
|
||
});
|
||
|
||
it.each([false, true])("keeps monitor errors on the checked task (late response: %s)", async (lateResponse) => {
|
||
const monitor = { monitor: { status: "scheduled", nextCheckAt: new Date(Date.now() + 60_000).toISOString(), attemptCount: 1, serviceName: "github" } } as Issue["executionState"];
|
||
mockIssuesApi.get.mockResolvedValue(createIssue({ status: "in_progress", executionState: monitor }));
|
||
const check = createDeferred<never>();
|
||
mockIssuesApi.checkMonitorNow.mockReturnValue(check.promise);
|
||
await act(async () => renderIssueView());
|
||
let button: HTMLButtonElement | undefined;
|
||
await waitForAssertion(() => {
|
||
button = Array.from(container.querySelectorAll("button")).find((entry) => entry.textContent === "Check now");
|
||
expect(button).toBeTruthy();
|
||
});
|
||
await act(async () => button!.click());
|
||
if (!lateResponse) {
|
||
check.reject(new Error("First task monitor failed"));
|
||
await waitForAssertion(() => expect(container.textContent).toContain("First task monitor failed"));
|
||
}
|
||
const second = createIssue({ id: "issue-2", identifier: "PAP-2", title: "Second monitored task", status: "in_progress", executionState: monitor });
|
||
mockIssuesApi.get.mockResolvedValue(second);
|
||
queryClient.setQueryData(queryKeys.issues.detail("PAP-2"), second);
|
||
mockRouteParams.issueId = "PAP-2";
|
||
mockLocation.pathname = "/issues/PAP-2";
|
||
await act(async () => renderIssueView());
|
||
if (lateResponse) check.reject(new Error("First task monitor failed"));
|
||
await flushReact();
|
||
await waitForAssertion(() => {
|
||
expect(container.textContent).toContain("Second monitored task");
|
||
expect(container.textContent).not.toContain("First task monitor failed");
|
||
expect(Array.from(container.querySelectorAll("button")).find((entry) => entry.textContent === "Check now")?.disabled).toBe(false);
|
||
});
|
||
});
|
||
|
||
afterEach(async () => {
|
||
await act(async () => {
|
||
root.unmount();
|
||
});
|
||
queryClient.clear();
|
||
container.remove();
|
||
document.body.innerHTML = "";
|
||
localStorage.clear();
|
||
sessionStorage.clear();
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
it.each(["new-project", null])("remembers project %s only after its task update succeeds", async (projectId) => {
|
||
const issue = createIssue({ projectId: "original-project" });
|
||
trackRecentProject("original-project", issue.companyId);
|
||
const failedUpdate = createDeferred<Issue>();
|
||
const successfulUpdate = createDeferred<Issue>();
|
||
mockIssuesApi.get.mockResolvedValue(issue);
|
||
mockIssuesApi.update.mockClear();
|
||
mockIssuesApi.update.mockReturnValueOnce(failedUpdate.promise).mockReturnValueOnce(successfulUpdate.promise);
|
||
await act(async () => renderIssueView());
|
||
let properties!: { onUpdate: (data: Record<string, unknown>) => void };
|
||
await waitForAssertion(() => {
|
||
properties = mockOpenPanel.mock.calls.at(-1)?.[0]?.props.children?.props;
|
||
expect(properties?.onUpdate).toBeTypeOf("function");
|
||
});
|
||
await act(async () => properties.onUpdate({ projectId }));
|
||
await waitForAssertion(() => expect(mockIssuesApi.update).toHaveBeenCalledTimes(1));
|
||
expect(getLastProjectId(issue.companyId)).toBe("original-project");
|
||
await act(async () => failedUpdate.reject(new Error("Project save failed")));
|
||
await waitForAssertion(() => expect(mockPushToast).toHaveBeenCalledWith(expect.objectContaining({ title: "Task update failed" })));
|
||
expect(getLastProjectId(issue.companyId)).toBe("original-project");
|
||
await act(async () => properties.onUpdate({ projectId }));
|
||
await waitForAssertion(() => expect(mockIssuesApi.update).toHaveBeenCalledTimes(2));
|
||
expect(getLastProjectId(issue.companyId)).toBe("original-project");
|
||
await act(async () => successfulUpdate.resolve({ ...issue, projectId }));
|
||
await waitForAssertion(() => expect(getLastProjectId(issue.companyId)).toBe(projectId ?? ""));
|
||
expect(getLastProjectId("company-2")).toBeUndefined();
|
||
});
|
||
|
||
it("keeps an existing conversation on its agent-addressed route", async () => {
|
||
const agent = createAgent();
|
||
const canonical = createIssue({ conversationAgentId: agent.id, conversationUserId: "user-1", conversationState: "waiting", status: "in_review" });
|
||
mockIssuesApi.get.mockResolvedValue(canonical);
|
||
await act(async () => {
|
||
renderIssueView(<TaskDetailSurface conversation={{ agent, issue: canonical, ensureIssue: async () => canonical }} />);
|
||
});
|
||
await flushReact();
|
||
expect(mockNavigate).not.toHaveBeenCalled();
|
||
expect(mockIssuesApi.markRead).toHaveBeenCalledWith(canonical.id);
|
||
const [breadcrumbs] = mockSetBreadcrumbs.mock.calls.at(-1)!;
|
||
expect(breadcrumbs[0].leading.type).toBe(AgentAvatar);
|
||
expect(breadcrumbs[0].leading.props).toMatchObject({ agent, size: 24 });
|
||
expect(breadcrumbs[0].leadingKey).toBe(`agent:${agent.id}:${JSON.stringify(agent.appearance)}`);
|
||
const updatedAgent = { ...agent, appearance: { schemaVersion: 1, characterVersion: "cap-v1", paletteId: "deep-tide" } } as Agent;
|
||
await act(async () => {
|
||
renderIssueView(<TaskDetailSurface conversation={{ agent: updatedAgent, issue: canonical, ensureIssue: async () => canonical }} />);
|
||
});
|
||
await flushReact();
|
||
const [updatedBreadcrumbs] = mockSetBreadcrumbs.mock.calls.at(-1)!;
|
||
expect(updatedBreadcrumbs[0].leading.props.agent).toEqual(updatedAgent);
|
||
expect(updatedBreadcrumbs[0].leadingKey).not.toBe(breadcrumbs[0].leadingKey);
|
||
|
||
});
|
||
|
||
it.each(["message", "attachment"])("creates an unused conversation only for the first %s and updates its canonical cache", async (kind) => {
|
||
mockIssuesApi.markRead.mockClear();
|
||
const agent = createAgent();
|
||
const canonical = createIssue({ conversationAgentId: agent.id, conversationUserId: "user-1", conversationState: "waiting", status: "in_review" });
|
||
const ensureIssue = vi.fn().mockResolvedValue(canonical);
|
||
const invalidate = vi.spyOn(queryClient, "invalidateQueries");
|
||
mockIssuesApi.addComment.mockResolvedValue(createIssueComment({ body: "Clarify this goal" }));
|
||
mockIssuesApi.uploadAttachment.mockResolvedValue(createAttachment({ id: "first-upload" }));
|
||
await act(async () => {
|
||
renderIssueView(<TaskDetailSurface conversation={{ agent, issue: null, ensureIssue }} />);
|
||
});
|
||
await flushReact();
|
||
expect(ensureIssue).not.toHaveBeenCalled();
|
||
expect(mockIssuesApi.markRead).not.toHaveBeenCalled();
|
||
const props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as {
|
||
onAdd: (body: string) => Promise<void>;
|
||
onAttachImage: (file: File) => Promise<IssueAttachment>;
|
||
};
|
||
if (kind === "message") {
|
||
await act(async () => { await props.onAdd("Clarify this goal"); });
|
||
expect(mockIssuesApi.addComment).toHaveBeenCalledWith(canonical.id, "Clarify this goal", undefined, undefined, undefined, expect.any(String));
|
||
expect(invalidate).toHaveBeenCalledWith({ queryKey: queryKeys.issues.comments(canonical.id) });
|
||
} else {
|
||
const file = new File(["image"], "first.png", { type: "image/png" });
|
||
await act(async () => { await props.onAttachImage(file); });
|
||
expect(mockIssuesApi.uploadAttachment).toHaveBeenCalledWith(canonical.companyId, canonical.id, file);
|
||
expect(invalidate).toHaveBeenCalledWith({ queryKey: queryKeys.issues.attachments(canonical.id) });
|
||
expect(invalidate).toHaveBeenCalledWith({ queryKey: queryKeys.issues.detail(canonical.id) });
|
||
}
|
||
expect(ensureIssue).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it("retries the chosen initial chat mode after creation succeeded but mode persistence failed", async () => {
|
||
mockIssuesApi.addComment.mockClear();
|
||
mockIssuesApi.update.mockClear();
|
||
const agent = createAgent();
|
||
const canonical = createIssue({ conversationAgentId: agent.id, conversationUserId: "user-1", conversationState: "waiting", status: "in_review", workMode: "standard" });
|
||
const ensureIssue = vi.fn().mockResolvedValue(canonical);
|
||
mockIssuesApi.update.mockRejectedValueOnce(new Error("Mode save failed")).mockResolvedValue({ ...canonical, workMode: "ask" });
|
||
mockIssuesApi.addComment.mockResolvedValue(createIssueComment({ body: "Research only" }));
|
||
const renderChat = async (issue: Issue | null) => {
|
||
await act(async () => renderIssueView(<TaskDetailSurface conversation={{ agent, issue, ensureIssue }} />));
|
||
await flushReact();
|
||
return mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as { onWorkModeChange: (mode: string) => Promise<void>; onAdd: (body: string) => Promise<void> };
|
||
};
|
||
let props = await renderChat(null);
|
||
await act(async () => props.onWorkModeChange("ask"));
|
||
props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0];
|
||
await act(async () => { await expect(props.onAdd("Research only")).rejects.toThrow("Mode save failed"); });
|
||
expect(mockIssuesApi.addComment).not.toHaveBeenCalled();
|
||
props = await renderChat(canonical);
|
||
await act(async () => props.onAdd("Research only"));
|
||
expect(mockIssuesApi.update).toHaveBeenNthCalledWith(2, canonical.id, { workMode: "ask" });
|
||
expect(mockIssuesApi.addComment).toHaveBeenCalledOnce();
|
||
});
|
||
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
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.each(["comments", "description", "empty"])("coordinates %s with the initial cards and supporting history", async (content) => {
|
||
const history = createDeferred<[]>();
|
||
mockIssuesApi.get.mockResolvedValue(createIssue({
|
||
description: content === "description" ? "Saved task description" : null,
|
||
}));
|
||
mockIssuesApi.listComments.mockResolvedValue(content === "comments" ? [createIssueComment()] : []);
|
||
mockActivityApi.forIssue.mockReturnValue(history.promise);
|
||
mockActivityApi.runsForIssue.mockReturnValue(history.promise);
|
||
mockHeartbeatsApi.liveRunsForIssue.mockReturnValue(history.promise);
|
||
mockIssuesApi.listInteractions.mockReturnValue(history.promise);
|
||
mockIssuesApi.listAttachments.mockReturnValue(history.promise);
|
||
mockIssuesApi.listWorkProducts.mockReturnValue(history.promise);
|
||
|
||
await act(async () => {
|
||
renderIssueView();
|
||
});
|
||
await waitForAssertion(() => {
|
||
expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0]).toMatchObject({
|
||
initialHistoryPending: true,
|
||
});
|
||
});
|
||
// Resolving metadata fills the same thread rather than replacing its content.
|
||
history.resolve([]);
|
||
await waitForAssertion(() => {
|
||
expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0]).toMatchObject({ initialHistoryPending: false });
|
||
});
|
||
});
|
||
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
|
||
// The task response may need slow workspace/recovery enrichment. The
|
||
// thread requests must already be in flight while its skeleton is showing.
|
||
expect(mockActivityApi.forIssue).toHaveBeenCalledWith("PAP-1");
|
||
expect(mockActivityApi.runsForIssue).toHaveBeenCalledWith("PAP-1");
|
||
|
||
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 () =>
|
||
renderIssueView(),
|
||
);
|
||
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",
|
||
commentClientRequestId: undefined,
|
||
assigneeAgentId: "agent-2",
|
||
assigneeUserId: null,
|
||
assigneeAdapterOverrides: null,
|
||
attachmentIds: [id],
|
||
});
|
||
expect(mockIssuesApi.addComment).not.toHaveBeenCalled();
|
||
} else {
|
||
expect(mockIssuesApi.addComment).toHaveBeenCalledWith(
|
||
issue.identifier,
|
||
"Inspect the new file",
|
||
undefined,
|
||
undefined,
|
||
[id],
|
||
expect.any(String),
|
||
);
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
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("routes the first document click through the mounted task, including UUID aliases", async () => {
|
||
mockLocation.pathname = "/PAP/issues/PAP-1";
|
||
mockLocation.state = { from: "inbox" };
|
||
mockIssuesApi.get.mockResolvedValue(createIssue());
|
||
await act(async () => {
|
||
renderIssueView();
|
||
});
|
||
await waitForAssertion(() => expect(queryClient.getQueryData(queryKeys.issues.detail("PAP-1"))).toBeDefined());
|
||
const getCalls = mockIssuesApi.get.mock.calls.length;
|
||
const link = document.createElement("a");
|
||
link.href = "/PAP/issues/issue-1#document-plan";
|
||
const closePreview = vi.fn((event: Event) => expect(event.defaultPrevented).toBe(true));
|
||
link.addEventListener("click", closePreview);
|
||
container.appendChild(link);
|
||
const event = new MouseEvent("click", { bubbles: true, cancelable: true });
|
||
await act(async () => { link.dispatchEvent(event); });
|
||
expect(event.defaultPrevented).toBe(true);
|
||
expect(closePreview).toHaveBeenCalledTimes(1);
|
||
expect(mockNavigate).toHaveBeenCalledWith("/PAP/issues/PAP-1#document-plan", {
|
||
preventScrollReset: true,
|
||
state: { from: "inbox", taskDocumentScrollEntry: { key: "task-entry", hash: "", pathname: "/PAP/issues/PAP-1" } },
|
||
});
|
||
expect(mockIssuesApi.get).toHaveBeenCalledTimes(getCalls);
|
||
});
|
||
|
||
it.each(["modifier", "new-tab", "download"])("preserves native document-link behavior for %s", async (kind) => {
|
||
mockIssuesApi.get.mockResolvedValue(createIssue());
|
||
await act(async () => {
|
||
renderIssueView();
|
||
});
|
||
const link = document.createElement("a");
|
||
link.href = "#document-plan";
|
||
if (kind === "new-tab") link.target = "_blank";
|
||
if (kind === "download") link.download = "plan.md";
|
||
container.appendChild(link);
|
||
const event = new MouseEvent("click", { bubbles: true, cancelable: true, metaKey: kind === "modifier" });
|
||
// Keep jsdom from following the native link after observing our handler.
|
||
link.addEventListener("click", () => expect(event.defaultPrevented).toBe(false), { once: true });
|
||
link.addEventListener("click", (click) => click.preventDefault(), { once: true });
|
||
await act(async () => { link.dispatchEvent(event); });
|
||
expect(mockNavigate).not.toHaveBeenCalled();
|
||
});
|
||
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
|
||
await waitForAssertion(() => {
|
||
expect(mockRequestPanelMaximize).toHaveBeenCalledTimes(2);
|
||
});
|
||
});
|
||
|
||
it("opens Artifacts for existing output without discarding a document deep link", async () => {
|
||
mockLocation.hash = "#document-agents";
|
||
mockIssuesApi.get.mockResolvedValue(createIssue({
|
||
documentSummaries: [{
|
||
id: "agents-doc", companyId: "company-1", issueId: "issue-1",
|
||
key: "agents", title: "AGENTS.md", format: "markdown",
|
||
latestRevisionId: "revision-1", latestRevisionNumber: 1,
|
||
createdByAgentId: "agent-1", createdByUserId: null,
|
||
updatedByAgentId: "agent-1", updatedByUserId: null,
|
||
lockedAt: null, lockedByAgentId: null, lockedByUserId: null,
|
||
createdAt: new Date(), updatedAt: new Date(),
|
||
}],
|
||
}));
|
||
await act(async () => {
|
||
renderIssueView();
|
||
});
|
||
await waitForAssertion(() => {
|
||
const props = mockOpenPanel.mock.calls.at(-1)?.[0]?.props.children?.props;
|
||
expect(props?.artifactsOpenRequestId).toBe(1);
|
||
expect(props?.documentDeepLink?.documentKey).toBe("agents");
|
||
});
|
||
});
|
||
|
||
it.each([false, true])("registers new artifacts without opening a closed panel (mobile: %s)", async (isMobile) => {
|
||
mockSidebarState.isMobile = isMobile;
|
||
mockLocation.state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox");
|
||
mockPanelState.panelVisible = false;
|
||
mockIssuesApi.get.mockResolvedValue(createIssue());
|
||
await act(async () => {
|
||
renderIssueView();
|
||
});
|
||
await waitForAssertion(() => {
|
||
expect(queryClient.getQueryData(queryKeys.issues.attachments("PAP-1"))).toEqual([]);
|
||
});
|
||
await flushReact();
|
||
const panelProps = () => (isMobile
|
||
? mockTaskSidePanelRender.mock.calls.at(-1)?.[0]
|
||
: mockOpenPanel.mock.calls.at(-1)?.[0]?.props.children?.props) as {
|
||
artifactsOpenRequestId?: number;
|
||
onArtifactsOpened: (requestId: number) => void;
|
||
};
|
||
const file = createAttachment({ id: "new-output", createdByAgentId: "agent-1" });
|
||
act(() => { queryClient.setQueryData(queryKeys.issues.attachments("PAP-1"), [file]); });
|
||
await flushReact();
|
||
expect(mockSetPanelVisible).not.toHaveBeenCalled();
|
||
expect(document.querySelector('[data-testid="mobile-task-side-panel"]')).toBeNull();
|
||
if (isMobile) {
|
||
const toolbar = mockSetMobileToolbar.mock.calls.map(([node]) => node).filter(Boolean).at(-1);
|
||
// The pending arrival is consumed only when the user opens the sheet.
|
||
act(() => toolbar.props.onProperties());
|
||
}
|
||
await waitForAssertion(() => expect(panelProps()?.artifactsOpenRequestId).toBe(1));
|
||
|
||
act(() => panelProps().onArtifactsOpened(1));
|
||
await waitForAssertion(() => expect(panelProps().artifactsOpenRequestId).toBeUndefined());
|
||
act(() => { queryClient.setQueryData(queryKeys.issues.attachments("PAP-1"), [{ ...file, originalFilename: "Renamed output" }]); });
|
||
await flushReact();
|
||
expect(panelProps().artifactsOpenRequestId).toBeUndefined();
|
||
|
||
act(() => { queryClient.setQueryData(queryKeys.issues.attachments("PAP-1"), [file,
|
||
createAttachment({ id: "next-output", createdByAgentId: "agent-1" }),
|
||
]); });
|
||
await waitForAssertion(() => expect(panelProps().artifactsOpenRequestId).toBe(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 () => {
|
||
renderIssueView();
|
||
});
|
||
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
|
||
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("inset-0 h-dvh max-h-dvh");
|
||
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 ancestors, subtask membership and created work independently and refreshes on issue activity", async () => {
|
||
const ancestors = [{ id: "parent-task", identifier: "PAP-0", title: "Parent task", status: "in_progress" }] as Issue["ancestors"];
|
||
const createdFrom: Issue["createdFrom"] = {
|
||
issue: { id: "origin-task", identifier: "PAP-168", title: "Origin task", status: "in_progress" },
|
||
run: { id: "run-1", agentId: "qa-agent" },
|
||
agent: { id: "qa-agent", name: "Paperclip QA" },
|
||
};
|
||
const source = createIssue({ ancestors, createdFrom });
|
||
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 () => { renderIssueView(); });
|
||
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()?.content.props.ancestors).toEqual(ancestors);
|
||
expect(taskProjection()?.content.props.createdFrom).toEqual(createdFrom);
|
||
expect(taskProjection()?.count).toBe(4);
|
||
|
||
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(5);
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
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.each(["success", "failure", "pending question"])("connects the inline disposition retry to current state (%s)", async outcome => {
|
||
const fail = outcome === "failure";
|
||
const actionId = "recovery-action-inline";
|
||
const snapshot: DispositionRecoverySnapshot = { kind: "disposition_repair_escalated", actionId, attemptCount: 2, maxAttempts: 2, reason: "unchanged_source_state_exhausted", assigneeAgentId: "agent-1" };
|
||
const issue = createIssue({ status: "blocked", assigneeAgentId: "agent-1", activeRecoveryAction: {
|
||
id: actionId, status: "active", kind: "deliberate_wait_without_target", ownerType: "board", returnOwnerAgentId: "agent-1", wakePolicy: { type: "board_escalation" },
|
||
companyId: "company-1", sourceIssueId: "issue-1", recoveryIssueId: null, ownerAgentId: null, ownerUserId: null,
|
||
previousOwnerAgentId: "agent-1", cause: "deliberate_wait_without_target", fingerprint: "fixture", evidence: {}, nextAction: "Retry", monitorPolicy: null,
|
||
attemptCount: 2, maxAttempts: 2, timeoutAt: null, lastAttemptAt: null, outcome: null, resolutionNote: null, resolvedAt: null, createdAt: new Date(), updatedAt: new Date(),
|
||
} });
|
||
mockIssuesApi.get.mockResolvedValue(issue);
|
||
mockIssuesApi.listComments.mockResolvedValue([{ id: "notice-inline", companyId: issue.companyId, issueId: issue.id, authorType: "system", body: "Unrelated prose", createdAt: new Date(), updatedAt: new Date(), metadata: { version: 1, sections: [], recovery: snapshot } }]);
|
||
if (outcome === "pending question") mockIssuesApi.listInteractions.mockResolvedValue([{ id: "question-1", kind: "ask_user_questions", status: "pending", payload: { version: 1, questions: [] } }]);
|
||
if (fail) mockIssuesApi.resolveRecoveryAction.mockRejectedValue(new Error("The task is now paused."));
|
||
else mockIssuesApi.resolveRecoveryAction.mockResolvedValue({ issue: { ...issue, status: "todo", activeRecoveryAction: null }, recoveryAction: { ...issue.activeRecoveryAction, status: "resolved" } });
|
||
await act(async () => renderIssueView());
|
||
await flushReact(); await flushReact();
|
||
const retry = Array.from(container.querySelectorAll("button")).find(b => b.textContent === "Retry agent");
|
||
expect(retry).toBeDefined();
|
||
if (outcome === "pending question") {
|
||
expect(retry!.disabled).toBe(true);
|
||
expect(container.textContent).toContain("Respond to the pending question or confirmation before retrying.");
|
||
await act(async () => retry!.click());
|
||
expect(mockIssuesApi.resolveRecoveryAction).not.toHaveBeenCalled();
|
||
mockIssuesApi.resolveRecoveryAction.mockReset();
|
||
return;
|
||
}
|
||
expect(retry!.disabled).toBe(false);
|
||
await act(async () => retry!.click());
|
||
await waitForAssertion(() => {
|
||
expect(mockIssuesApi.resolveRecoveryAction).toHaveBeenCalledExactlyOnceWith(issue.identifier, { actionId, outcome: "restored", sourceIssueStatus: "todo" });
|
||
expect(container.textContent).toContain(fail ? "Couldn’t confirm the retry. The task is now paused." : "Retry requested");
|
||
});
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
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());
|
||
mockAuthApi.getSession.mockResolvedValue({ session: { userId: "user-1" }, user: { id: "user-1" } });
|
||
mockInstanceSettingsApi.getGeneral.mockResolvedValue({
|
||
feedbackDataSharingPreference: "prompt",
|
||
});
|
||
|
||
await act(async () => {
|
||
renderIssueView();
|
||
});
|
||
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());
|
||
mockAuthApi.getSession.mockResolvedValue({ session: { userId: "user-1" }, user: { id: "user-1" } });
|
||
mockInstanceSettingsApi.getGeneral.mockResolvedValue({
|
||
feedbackDataSharingPreference: "prompt",
|
||
});
|
||
|
||
await act(async () => {
|
||
renderIssueView();
|
||
});
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
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.each(["native", "legacy"] as const)("projects a native follow-up into the steering well before the post resolves with %s persisted mode", async (runtimeMode) => {
|
||
const postedComment = createDeferred<IssueComment>();
|
||
const activeRun = {
|
||
id: "run-native",
|
||
runtimeMode,
|
||
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: [],
|
||
protocol: "legacy",
|
||
steeringDisposition: "temporarily_unavailable",
|
||
}),
|
||
);
|
||
mockIssuesApi.addComment.mockReturnValue(postedComment.promise);
|
||
|
||
await act(async () => {
|
||
renderIssueView();
|
||
});
|
||
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.each(([
|
||
["history", "before"],
|
||
["history", "after"],
|
||
["live", "before"],
|
||
["live", "after"],
|
||
] as const).flatMap(([source, order]) =>
|
||
(["same-run", "previous-run", "queued", "adopted"] as const).map(queueTarget => [source, order, queueTarget] as const),
|
||
))("retires a consumed follow-up from %s when its post resolves %s delivery with a %s snapshot", async (receiptSource, postOrder, queueTarget) => {
|
||
const postedComment = createDeferred<IssueComment>();
|
||
const delivered = createIssueComment({ id: "delivered", body: "Use this direction" });
|
||
const waiting = createIssueComment({ id: "still-waiting", body: "Keep this queued" });
|
||
const run = {
|
||
id: queueTarget === "same-run" ? "run-native" : "run-original", 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", executionRunId: run.id }));
|
||
mockAgentsApi.list.mockResolvedValue([createAgent({ adapterType: "paperclip_runner" })]);
|
||
mockHeartbeatsApi.activeRunForIssue.mockResolvedValue(run);
|
||
mockHeartbeatsApi.liveRunsForIssue.mockResolvedValue([run]);
|
||
mockIssuesApi.addComment.mockReturnValue(postedComment.promise);
|
||
await act(async () => renderIssueView());
|
||
await flushReact();
|
||
|
||
type Props = {
|
||
onAdd: (body: string) => Promise<void>;
|
||
comments: Array<Record<string, unknown>>;
|
||
queuedCommentQueue: IssueQueuedCommentQueue | null;
|
||
};
|
||
const props = () => mockIssueChatThreadRender.mock.calls.at(-1)![0] as Props;
|
||
await act(async () => { void props().onAdd(delivered.body); });
|
||
await waitForAssertion(() => {
|
||
expect(props().queuedCommentQueue?.entries[0]?.comment.body).toBe(delivered.body);
|
||
});
|
||
if (postOrder === "before") {
|
||
await act(async () => postedComment.resolve(delivered));
|
||
await flushReact();
|
||
}
|
||
|
||
const staleQueue = createQueuedCommentQueue({
|
||
state: queueTarget === "queued" ? "queued" : "deferred",
|
||
targetRunId: queueTarget === "queued" ? null : run.id,
|
||
entries: [delivered, waiting].map((comment, position) => ({
|
||
comment, position, canEdit: true, canDiscard: true,
|
||
})),
|
||
});
|
||
mockIssuesApi.listComments.mockResolvedValue([delivered, waiting]);
|
||
mockIssuesApi.getQueuedComments.mockResolvedValue(staleQueue);
|
||
await act(async () => {
|
||
await queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments("PAP-1") });
|
||
queryClient.setQueryData(queryKeys.issues.queuedComments("issue-1"), staleQueue);
|
||
if (receiptSource === "history") {
|
||
queryClient.setQueryData(queryKeys.issues.runs("PAP-1"), [{
|
||
...run, runId: "run-native",
|
||
wakeupRequestId: queueTarget === "adopted" ? "other-wake" : staleQueue.queueId,
|
||
queuedCommentSourceWakeIds: queueTarget === "adopted" ? [staleQueue.queueId] : null,
|
||
wakeCommentIds: [delivered.id],
|
||
contextIssueId: "issue-1", logBytes: 1,
|
||
}]);
|
||
} else {
|
||
const consumingRun = {
|
||
...run, id: "run-native",
|
||
wakeupRequestId: queueTarget === "adopted" ? "other-wake" : staleQueue.queueId,
|
||
queuedCommentSourceWakeIds: queueTarget === "adopted" ? [staleQueue.queueId] : null,
|
||
contextCommentId: delivered.id, contextWakeCommentId: delivered.id,
|
||
};
|
||
mockHeartbeatsApi.liveRunsForIssue.mockResolvedValue([consumingRun]);
|
||
mockHeartbeatsApi.activeRunForIssue.mockResolvedValue(consumingRun);
|
||
queryClient.setQueryData(queryKeys.issues.liveRuns("PAP-1"), [consumingRun]);
|
||
queryClient.setQueryData(queryKeys.issues.activeRun("PAP-1"), consumingRun);
|
||
}
|
||
});
|
||
if (postOrder === "after") {
|
||
await act(async () => postedComment.resolve(delivered));
|
||
}
|
||
await waitForAssertion(() => {
|
||
expect(props().queuedCommentQueue?.entries.map(entry => entry.comment.id)).toEqual([waiting.id]);
|
||
const comment = props().comments.find(comment => comment.id === delivered.id);
|
||
expect(comment).toMatchObject({ consumedByRunId: "run-native" });
|
||
expect(comment?.queueState).toBeUndefined();
|
||
expect(comment?.clientStatus).toBeUndefined();
|
||
expect(props().comments.filter(comment => comment.body === delivered.body)).toHaveLength(1);
|
||
});
|
||
await act(async () => {
|
||
await queryClient.invalidateQueries({ queryKey: queryKeys.issues.queuedComments("issue-1") });
|
||
});
|
||
expect(props().queuedCommentQueue?.entries.map(entry => entry.comment.id)).toEqual([waiting.id]);
|
||
});
|
||
|
||
it("keeps an admitted message pending until its queued run starts", async () => {
|
||
const queued = createIssueComment({ id: "not-started" });
|
||
mockIssuesApi.get.mockResolvedValue(createIssue({ status: "in_progress", assigneeAgentId: "agent-1", executionRunId: "run-queued" }));
|
||
mockIssuesApi.listComments.mockResolvedValue([queued]);
|
||
mockIssuesApi.getQueuedComments.mockResolvedValue(createQueuedCommentQueue({
|
||
state: "queued", targetRunId: null,
|
||
entries: [{ comment: queued, position: 0, canEdit: true, canDiscard: true }],
|
||
}));
|
||
mockActivityApi.runsForIssue.mockResolvedValue([{
|
||
runId: "run-queued", agentId: "agent-1", adapterType: "paperclip_runner",
|
||
status: "queued", startedAt: null, finishedAt: null,
|
||
createdAt: "2026-04-21T00:00:01.000Z", wakeCommentIds: [queued.id],
|
||
wakeupRequestId: "wake-queue-1",
|
||
contextIssueId: "issue-1",
|
||
}]);
|
||
await act(async () => renderIssueView());
|
||
await waitForAssertion(() => {
|
||
const props = mockIssueChatThreadRender.mock.calls.at(-1)![0];
|
||
expect(props.queuedCommentQueue?.entries.map((entry: { comment: IssueComment }) => entry.comment.id)).toEqual([queued.id]);
|
||
expect(props.comments.find((comment: IssueComment) => comment.id === queued.id)?.consumedByRunId).toBeUndefined();
|
||
});
|
||
});
|
||
|
||
it.each(["queued", "deferred"] as const)("preserves an authoritative %s retry after an older run took the same input", async state => {
|
||
const retry = createIssueComment({ id: "retry-message" });
|
||
mockIssuesApi.get.mockResolvedValue(createIssue({ status: "in_progress", assigneeAgentId: "agent-1" }));
|
||
mockIssuesApi.listComments.mockResolvedValue([retry]);
|
||
mockIssuesApi.getQueuedComments.mockResolvedValue(createQueuedCommentQueue({
|
||
state, targetRunId: state === "deferred" ? "run-new" : null,
|
||
entries: [{ comment: retry, position: 0, canEdit: true, canDiscard: true }],
|
||
}));
|
||
const originalRun = {
|
||
runId: "run-old", agentId: "agent-1", adapterType: "paperclip_runner",
|
||
status: "failed", startedAt: "2026-04-21T00:00:01.000Z",
|
||
finishedAt: "2026-04-21T00:00:02.000Z",
|
||
createdAt: "2026-04-21T00:00:01.000Z", wakeCommentIds: [retry.id],
|
||
wakeupRequestId: "old-wake-queue",
|
||
contextIssueId: "issue-1",
|
||
};
|
||
mockActivityApi.runsForIssue.mockResolvedValue([originalRun]);
|
||
await act(async () => renderIssueView());
|
||
await waitForAssertion(() => {
|
||
const props = mockIssueChatThreadRender.mock.calls.at(-1)![0];
|
||
expect(props.queuedCommentQueue?.entries.map((entry: { comment: IssueComment }) => entry.comment.id)).toEqual([retry.id]);
|
||
});
|
||
await act(async () => {
|
||
queryClient.setQueryData(queryKeys.issues.runs("PAP-1"), [originalRun, {
|
||
...originalRun, runId: "run-new", status: "running", finishedAt: null,
|
||
startedAt: "2026-04-21T00:00:06.000Z", wakeupRequestId: "wake-queue-1",
|
||
}]);
|
||
});
|
||
await waitForAssertion(() => {
|
||
expect(mockIssueChatThreadRender.mock.calls.at(-1)![0].queuedCommentQueue).toBeNull();
|
||
});
|
||
});
|
||
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
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("stops the selected queued Grok run without cancelling a different running run", async () => {
|
||
mockGrokBotApi.stop.mockClear();
|
||
mockIssuesApi.get.mockResolvedValue(createIssue({ status: "in_progress", assigneeAgentId: "agent-1", executionRunId: "run-active-1" }));
|
||
mockAgentsApi.list.mockResolvedValue([
|
||
createAgent({ adapterType: "paperclip_runner", adapterConfig: { provider: "grok_bot" } }),
|
||
createAgent({ id: "agent-2", adapterType: "paperclip_runner", adapterConfig: { provider: "grok_bot" } }),
|
||
]);
|
||
mockHeartbeatsApi.liveRunsForIssue.mockResolvedValue([
|
||
{ id: "run-active-1", agentId: "agent-1", status: "running", runtimeMode: "native", issueId: "issue-1", adapterType: "paperclip_runner" },
|
||
{ id: "run-queued-2", agentId: "agent-2", status: "queued", runtimeMode: "native", issueId: "issue-1", adapterType: "paperclip_runner" },
|
||
]);
|
||
await act(async () => { renderIssueView(); });
|
||
await waitForAssertion(() => expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0]).toMatchObject({ stopRunLabel: "Stop Paperclip work" }));
|
||
await act(async () => { await mockIssueChatThreadRender.mock.calls.at(-1)![0].onStopRun("run-queued-2"); });
|
||
expect(mockGrokBotApi.stop).toHaveBeenCalledExactlyOnceWith("company-1", "agent-2", "run-queued-2");
|
||
expect(mockHeartbeatsApi.cancel).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it.each(["active-run", "composer"])(
|
||
"keeps %s run controls distinct from pausing future work",
|
||
async (control) => {
|
||
mockIssuesApi.createTreeHold.mockClear();
|
||
mockHeartbeatsApi.cancel.mockClear();
|
||
mockHeartbeatsApi.get.mockReset();
|
||
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",
|
||
},
|
||
]);
|
||
mockHeartbeatsApi.get.mockResolvedValue({ id: "run-active-1", status: "cancelled", runtimeMode: "legacy" });
|
||
mockAuthApi.getSession.mockResolvedValue({
|
||
session: { userId: "user-1" },
|
||
user: { id: "user-1" },
|
||
});
|
||
|
||
await act(async () => {
|
||
renderIssueView();
|
||
});
|
||
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();
|
||
|
||
if (control === "composer") {
|
||
expect(mockHeartbeatsApi.cancel).toHaveBeenCalledWith("run-active-1");
|
||
expect(mockHeartbeatsApi.get).toHaveBeenCalledWith("run-active-1");
|
||
expect(mockIssuesApi.createTreeHold).not.toHaveBeenCalled();
|
||
} else 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 () => {
|
||
mockHeartbeatsApi.cancel.mockClear();
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
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");
|
||
});
|
||
|
||
describe.each([false, true])("composer tree control (mobile=%s)", (isMobile) => {
|
||
const treeControlKey = ["issues", "tree-control-state", "PAP-1"];
|
||
const composerProps = () => mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as {
|
||
composerDisabledReason: string | null;
|
||
composerPause: TaskComposerPause | null;
|
||
onAdd: (body: string) => Promise<void>;
|
||
};
|
||
|
||
async function renderWithPendingTreeControl() {
|
||
mockSidebarState.isMobile = isMobile;
|
||
mockIssuesApi.get.mockResolvedValue(createIssue());
|
||
let resolve!: (value: Awaited<ReturnType<typeof issuesApi.getTreeControlState>>) => void;
|
||
let reject!: (reason: Error) => void;
|
||
const promise = new Promise<Awaited<ReturnType<typeof issuesApi.getTreeControlState>>>((resolvePromise, rejectPromise) => {
|
||
resolve = resolvePromise;
|
||
reject = rejectPromise;
|
||
});
|
||
const response = { promise, resolve, reject };
|
||
mockIssuesApi.getTreeControlState.mockReturnValue(response.promise);
|
||
await act(async () => {
|
||
root.render(
|
||
<QueryClientProvider client={queryClient}>
|
||
<IssueDetail />
|
||
</QueryClientProvider>,
|
||
);
|
||
});
|
||
await waitForAssertion(() => {
|
||
expect(mockIssuesApi.getTreeControlState).toHaveBeenCalledWith("PAP-1");
|
||
expect(mockIssueChatThreadRender).toHaveBeenCalled();
|
||
expect(queryClient.getQueryState(treeControlKey)?.status).toBe("pending");
|
||
expect(queryClient.getQueryState(treeControlKey)?.fetchStatus).toBe("fetching");
|
||
});
|
||
return response;
|
||
}
|
||
|
||
it("allows a message while tree control is pending and remains enabled after success", async () => {
|
||
mockIssuesApi.addComment.mockClear().mockResolvedValue(createIssueComment({ body: "Keep working" }));
|
||
const response = await renderWithPendingTreeControl();
|
||
expect(composerProps().composerDisabledReason).toBeNull();
|
||
expect(composerProps().composerPause).toBeNull();
|
||
await act(async () => {
|
||
await composerProps().onAdd("Keep working");
|
||
});
|
||
expect(mockIssuesApi.addComment).toHaveBeenCalledWith(
|
||
"PAP-1", "Keep working", undefined, undefined, undefined, expect.any(String),
|
||
);
|
||
expect(queryClient.getQueryState(treeControlKey)?.status).toBe("pending");
|
||
|
||
response.resolve({ activePauseHold: null });
|
||
await waitForAssertion(() => {
|
||
expect(queryClient.getQueryState(treeControlKey)?.status).toBe("success");
|
||
expect(composerProps().composerDisabledReason).toBeNull();
|
||
expect(composerProps().composerPause).toBeNull();
|
||
});
|
||
});
|
||
|
||
it("blocks the composer when the pending tree control request fails", async () => {
|
||
const response = await renderWithPendingTreeControl();
|
||
response.reject(new Error("tree control unavailable"));
|
||
await waitForAssertion(() => {
|
||
expect(queryClient.getQueryState(treeControlKey)?.status).toBe("error");
|
||
expect(composerProps().composerDisabledReason).toBe(
|
||
"Couldn’t check whether this task is paused. Refresh to try again.",
|
||
);
|
||
});
|
||
});
|
||
|
||
it.each([true, false])("applies a late pause response (root=%s)", async (isRoot) => {
|
||
const response = await renderWithPendingTreeControl();
|
||
response.resolve({
|
||
activePauseHold: {
|
||
holdId: "hold-1",
|
||
rootIssueId: isRoot ? "issue-1" : "parent-1",
|
||
issueId: "issue-1",
|
||
isRoot,
|
||
mode: "pause",
|
||
reason: null,
|
||
releasePolicy: { strategy: "manual" },
|
||
},
|
||
});
|
||
await waitForAssertion(() => {
|
||
expect(composerProps().composerPause?.scope).toBe(isRoot ? "leaf" : "subtree");
|
||
expect(container.querySelector('[data-testid="paused-composer-takeover"]')).not.toBeNull();
|
||
});
|
||
});
|
||
});
|
||
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
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.each([true, false])("optimistically promotes the last steering message and restores it without a toast on rejection (history loaded: %s)", async historyLoaded => {
|
||
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.listComments.mockResolvedValue(historyLoaded ? [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",
|
||
});
|
||
let rejectSteer!: (error: Error) => void;
|
||
mockIssuesApi.steerQueuedComment.mockReturnValue(new Promise((_, reject) => { rejectSteer = reject; }));
|
||
await act(async () => { renderIssueView(); });
|
||
type Props = { onSteerQueuedComment: (id: string, revision: string) => Promise<void>; queuedCommentQueue: IssueQueuedCommentQueue | null };
|
||
let props!: Props;
|
||
await waitForAssertion(() => {
|
||
props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as Props;
|
||
expect(props.queuedCommentQueue?.entries).toHaveLength(1);
|
||
expect(props.queuedCommentQueue?.queueId).toBe("wake-queue-1");
|
||
expect(props.queuedCommentQueue?.targetRunId).toBe("run-active-1");
|
||
});
|
||
let pending!: Promise<unknown>;
|
||
await act(async () => { pending = props.onSteerQueuedComment("queued-comment-1", queue.revision).catch(error => error); });
|
||
const whilePending = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as Props;
|
||
expect(whilePending.queuedCommentQueue).toBeNull();
|
||
expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0]?.comments).toContainEqual(expect.objectContaining({
|
||
id: "queued-comment-1", steeredIntoRunId: "run-active-1", queueState: undefined,
|
||
}));
|
||
const failure = new ApiError("This runner does not support steering", 409, { code: "steering_unsupported" });
|
||
await act(async () => { rejectSteer(failure); await pending; });
|
||
expect(await pending).toBe(failure);
|
||
await waitForAssertion(() => {
|
||
expect((mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as Props).queuedCommentQueue?.entries).toHaveLength(1);
|
||
});
|
||
expect(mockPushToast).not.toHaveBeenCalled();
|
||
});
|
||
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
|
||
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>;
|
||
let deliverySettled = false;
|
||
await act(async () => {
|
||
steeringPromise = steer("queued-comment-1", queue.revision).then(() => { deliverySettled = true; });
|
||
await Promise.resolve();
|
||
});
|
||
await waitForAssertion(() => {
|
||
expect(mockIssuesApi.steerQueuedComment).toHaveBeenCalled();
|
||
expect(mockActivityApi.forIssue.mock.calls.length).toBeGreaterThan(1);
|
||
expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0]?.queuedCommentQueue).toBeNull();
|
||
expect(deliverySettled).toBe(true);
|
||
});
|
||
|
||
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.each([
|
||
["success", true], ["failure", true], ["success", false], ["failure", false],
|
||
] as const)("optimistically promotes the interrupt queue with no toast on %s (history loaded: %s)", async (outcome, historyLoaded) => {
|
||
const queue = createQueuedCommentQueue({ protocol: "legacy", steeringDisposition: "unsupported" });
|
||
queue.entries.push({ ...queue.entries[0], position: 1, comment: createIssueComment({ id: "queued-comment-2", body: "Second direction" }) });
|
||
mockIssuesApi.get.mockResolvedValue(createIssue({ status: "in_progress", assigneeAgentId: "agent-1", executionRunId: "run-active-1" }));
|
||
mockAgentsApi.list.mockResolvedValue([createAgent({ adapterType: "codex_local" })]);
|
||
mockIssuesApi.listComments.mockResolvedValue(historyLoaded ? queue.entries.map(entry => entry.comment) : []);
|
||
mockIssuesApi.getQueuedComments.mockResolvedValue(queue);
|
||
mockHeartbeatsApi.activeRunForIssue.mockResolvedValue({
|
||
id: "run-active-1", runtimeMode: "legacy", status: "running", invocationSource: "issue",
|
||
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",
|
||
});
|
||
const delivery = createDeferred<IssueQueuedCommentQueue>();
|
||
mockIssuesApi.interruptQueuedComments.mockReturnValue(delivery.promise);
|
||
await act(async () => { renderIssueView(); });
|
||
type Props = { onInterruptQueued: (runId: string | null) => Promise<void>; queuedCommentQueue: IssueQueuedCommentQueue | null; comments: Array<{ id: string; queueState?: string; conversationAnchorAt?: string }> };
|
||
let props!: Props;
|
||
await waitForAssertion(() => {
|
||
props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as Props;
|
||
expect(props.queuedCommentQueue?.entries).toHaveLength(2);
|
||
});
|
||
let pending!: Promise<unknown>;
|
||
await act(async () => { pending = props.onInterruptQueued("run-active-1").catch(error => error); });
|
||
const whilePending = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as Props;
|
||
expect(whilePending.queuedCommentQueue).toBeNull();
|
||
for (const entry of queue.entries) expect(whilePending.comments).toContainEqual(expect.objectContaining({
|
||
id: entry.comment.id, queueState: undefined, conversationAnchorAt: expect.any(String),
|
||
}));
|
||
expect(mockPushToast).not.toHaveBeenCalled();
|
||
await act(async () => {
|
||
if (outcome === "failure") delivery.reject(new Error("Connection lost"));
|
||
else delivery.resolve(createQueuedCommentQueue({ entries: [] }));
|
||
await pending;
|
||
});
|
||
await waitForAssertion(() => {
|
||
const final = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as Props;
|
||
if (outcome === "failure") expect(final.queuedCommentQueue?.entries).toHaveLength(2);
|
||
else expect(final.queuedCommentQueue).toBeNull();
|
||
});
|
||
expect(mockPushToast).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("restores a failed classic interrupt inline without a toast or an unhandled rejection", async () => {
|
||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableStreamlinedUi: true, enableClassicTaskInterface: true });
|
||
mockIssuesApi.get.mockResolvedValue(createIssue({ status: "in_progress", assigneeAgentId: "agent-1", executionRunId: "run-active-1" }));
|
||
mockAgentsApi.list.mockResolvedValue([createAgent({ adapterType: "codex_local" })]);
|
||
mockIssuesApi.listComments.mockResolvedValue([createIssueComment({ id: "queued-comment-1", body: "Next direction", createdAt: new Date("2026-04-21T00:00:05.000Z") })]);
|
||
mockHeartbeatsApi.activeRunForIssue.mockResolvedValue({
|
||
id: "run-active-1", runtimeMode: "legacy", status: "running", invocationSource: "issue",
|
||
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",
|
||
});
|
||
mockIssuesApi.getQueuedComments.mockResolvedValue(createQueuedCommentQueue({ protocol: "legacy", steeringDisposition: "unsupported" }));
|
||
const delivery = createDeferred<IssueQueuedCommentQueue>();
|
||
mockIssuesApi.interruptQueuedComments.mockReturnValue(delivery.promise);
|
||
await act(async () => { renderIssueView(); });
|
||
type Props = { onInterruptQueued: (runId: string | null) => Promise<void>; comments: Array<{ id: string; queueState?: string }> };
|
||
let props!: Props;
|
||
await waitForAssertion(() => {
|
||
props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as Props;
|
||
expect(props.comments).toContainEqual(expect.objectContaining({ id: "queued-comment-1", queueState: "queued" }));
|
||
});
|
||
let pending!: Promise<unknown>;
|
||
await act(async () => { pending = props.onInterruptQueued("run-active-1").catch(error => error); });
|
||
expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0]?.comments).toContainEqual(expect.objectContaining({ id: "queued-comment-1", queueState: undefined }));
|
||
await act(async () => { delivery.reject(new Error("Connection lost")); await pending; });
|
||
expect(await pending).toBeUndefined();
|
||
await waitForAssertion(() => {
|
||
expect(container.textContent).toContain("Couldn’t interrupt. Message is still queued.");
|
||
expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0]?.comments).toContainEqual(expect.objectContaining({ id: "queued-comment-1", queueState: "queued" }));
|
||
});
|
||
expect(mockPushToast).not.toHaveBeenCalled();
|
||
});
|
||
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
|
||
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 () => {
|
||
renderIssueView();
|
||
});
|
||
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);
|
||
});
|
||
});
|