mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 07:12:00 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - People enter that work through the workspace, agent conversations, and tasks. > - Coordinator and Agent Chat still require experimental enrollment. > - Task link prefetches do not warm the compact queries used by the default task view. Chat also waits for a request when its navigation list already holds the conversation. > - This pull request makes Coordinator and Agent Chat standard and reuses the reads needed for navigation. > - People can choose Classic workspace in Experimental settings. Cached work can appear while fresh data loads. ## Linked Issues or Issue Description **What existing behavior does this improve?** Workspace selection, Agent Chat availability, and navigation between Chat, Tasks, and task details. **Subsystem affected** Cross-cutting: UI, workspace preferences, instance settings, and the database migration. **Current behavior** People must enable Coordinator and Agent Chat in Experimental settings. Opening a task can repeat requests because prefetch warms the full detail and comment queries, while the default view reads compact task data and a recent comment window. Opening Chat waits for a detail request even when the current person's conversation list contains it. **Proposed behavior** Coordinator is the standard workspace for everyone. Experimental settings offer Classic workspace, off by default. Old Coordinator experiment values do not select Classic. Agent Chat is available to everyone, including instances with an old saved or managed off value. Task link intent warms the queries used by the mounted view. Chat can use the current person's company-scoped conversation list during refresh. **Reason and benefit** People can use the standard workspace without setup. Repeat navigation can show cached content while the server refreshes it. **Breaking changes** Existing Coordinator experiment and Agent Chat off values no longer disable these surfaces. Classic requires an explicit new opt-in. The workspace API keeps its existing response shape and audit checks. The migration retains legacy values and adds a separate Classic preference. Execution permissions, company access, and chat privacy checks still apply. **Additional context** Roadmap check: this graduates existing surfaces and improves their navigation. It does not add a new agent execution capability. Duplicate searches found no open Coordinator workspace PR. Related server cache work, Refs: #17828, addresses compact issue-list invalidation; this PR addresses client navigation queries. ## What Changed - Add a default-off Classic workspace preference. Derive the existing workspace API response from that choice. Keep personal persistence, account isolation, and audited writes in the workspace preference service. - Replace the Coordinator experiment control with Classic workspace. Use Coordinator while preferences load. Update the existing settings examples and navigation recipe. - Retire the Agent Chat experiment control. Ignore old stored and managed off values. Retain the compatibility key and the existing execution and access checks. - Reuse account- and company-scoped chat list data with its original freshness timestamp. - Share compact task and recent-comment query options between prefetch and the task view. Keep full-history prefetch for Classic tasks and expanded history. - Add upgrade, replay, cache, account isolation, and browser switching regressions. Update product, database, development, and roadmap documentation. ## Verification - Targeted preference, settings, migration, cache, and UI regression tests passed during implementation. The cache tests hold refresh requests pending and check that content still renders. They also check account isolation and stale refresh behavior. - A real browser test verified fresh Coordinator defaults, Classic opt-in persistence, and return to Coordinator. It used an isolated source checkout with fresh data on macOS. - Token, migration numbering and safety, map, and Storybook catalog checks pass after the rebase. - Repository typecheck and build pass. Typecheck and token gates also pass after review repairs. The broad local run passed all six chat integration shards (1,931 tests). It then found the host-CLI-dependent recovery fixture described below. Its remaining duplicate work was stopped after the full current-head CI matrix passed; this is not a claim that the broad local command completed green. - Review repairs passed 419 UI/migration fixture tests, 19 Coordinator navigation tests, and all 214 tests in the five affected server suites. The 24 Chat/workspace browser tests pass, including standard Coordinator switching and explicit Classic persistence. - The broader browser repair run passed 85 tests. It retained two failed assertions and skipped five later serial cases. The affected files then passed all 11 tests after the fixes: reopen the desktop panel after reload and open More for Audit and Connectors. This includes the five serial cases that had not run. - The broad local suite exposed a recovery fixture that executed the host Codex CLI during a simulated remote probe. On a host with Codex 0.154.0, model fallback broke its fixed-model assertion. The fake sandbox now reports Codex 0.160.0. All 20 selected recovery/provider compatibility cases pass after the repair. - A CLI company import/export suite timed out during server startup in the second CI run. The port fallback is intentional in that fixture. No root cause was established. That suite passes on the final commit in CI. This records recovery, not a claimed product fix for the earlier timeout. - The first CI run exposed remaining assumptions about the retired flags and Classic UI. The repair commit updates those fixtures and navigation steps. It also restores the missing enabled Cases destination in Coordinator. Earlier local recovery attempts used the wrong fixture notification and advanced fake time during live execution; the corrected tests use the subscribed recovery queue and wall time during execution. - Greptile reviewed commit `98972b030d4505d838427a2fca6b196b8cad6d47` at **5/5** with no new comments. The original finding is resolved. - Current-head CI is green: **56 successful checks**, with three checks skipped by workflow filters. This includes typecheck, build, the complete Vitest matrix, all eight browser shards, Runner verification, release checks, and security checks. [Final CI run](https://github.com/paperclipai/paperclip/actions/runs/42132651414). - Typecheck caught missing `pid` and `startedAt` fields in the simulated command result. The fixture now returns the complete process contract, and the server test-layer typecheck passes. - Storybook catalog alignment was checked. Storybook play tests were not run. Hosted latency and cold bundle loading were not measured. ## Risks - The new default changes the workspace for people who previously left the Coordinator experiment off. They can opt into Classic in Experimental settings. - The additive migration must run before the new server code. It is safe to replay and preserves explicit Classic choices. - Cached content can be stale during background refresh. Existing query invalidation and source timestamps still control freshness. - These changes reduce avoidable loading waits. They do not establish a measured improvement in hosted request latency or initial bundle download time. ## Model Used OpenAI Codex, based on GPT-6, with reasoning, repository tools, code execution, and browser automation. The exact serving revision and context-window size 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 #` / `Refs #` 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: Scott Tong <scott@scottsmbpm5max.lan> Co-authored-by: Paperclip <noreply@paperclip.ing>
1203 lines
45 KiB
TypeScript
1203 lines
45 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { act, forwardRef, useImperativeHandle, useState, type ForwardedRef } from "react";
|
|
import { flushSync } from "react-dom";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
vi.mock("@/hooks/useCoordinatorWorkspaceEnabled", () => ({
|
|
useCoordinatorWorkspaceEnabled: () => ({ enabled: false, loaded: true }),
|
|
}));
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import type { IssueDocument } from "@paperclipai/shared";
|
|
import type {
|
|
IssueThreadInteraction,
|
|
RequestConfirmationInteraction,
|
|
} from "@/lib/issue-thread-interactions";
|
|
import { ThemeProvider } from "@/context/ThemeContext";
|
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
import {
|
|
expiredSecretProposalInteraction,
|
|
pendingToolActionLongProposalInteraction,
|
|
pendingAskUserQuestionsInteraction,
|
|
pendingRequestCheckboxConfirmationInteraction,
|
|
pendingRequestItemVerdictsInteraction,
|
|
} from "@/fixtures/issueThreadInteractionFixtures";
|
|
import { TaskChatInteractionCard } from "./TaskChatInteractionCard";
|
|
vi.mock("@/lib/router", async importOriginal => ({
|
|
...await importOriginal<typeof import("@/lib/router")>(),
|
|
Link: ({ to, children }: { to: string; children: React.ReactNode }) => <a href={to}>{children}</a>,
|
|
}));
|
|
import { TaskChatThreadView } from "./TaskChatThreadView";
|
|
import type { TaskChatInteractionItem } from "./task-chat-model";
|
|
|
|
vi.mock("@/components/MarkdownEditor", () => ({
|
|
MarkdownEditor: forwardRef(function MockMarkdownEditor(
|
|
{
|
|
value,
|
|
onChange,
|
|
placeholder,
|
|
imageUploadHandler,
|
|
}: {
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
placeholder?: string;
|
|
imageUploadHandler?: (file: File) => Promise<string>;
|
|
},
|
|
ref: ForwardedRef<unknown>,
|
|
) {
|
|
useImperativeHandle(ref, () => ({
|
|
insertMarkdown: (markdown: string) => onChange(`${value}${markdown}`),
|
|
focus: () => {},
|
|
}));
|
|
return (
|
|
<textarea
|
|
data-testid="mock-markdown-editor"
|
|
data-image-upload={imageUploadHandler ? "true" : "false"}
|
|
value={value}
|
|
placeholder={placeholder}
|
|
onChange={(event) => onChange(event.target.value)}
|
|
/>
|
|
);
|
|
}),
|
|
}));
|
|
|
|
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
function createRequestConfirmation(
|
|
overrides: Partial<RequestConfirmationInteraction> = {},
|
|
): RequestConfirmationInteraction {
|
|
return {
|
|
id: "confirmation-1",
|
|
companyId: "company-1",
|
|
issueId: "issue-1",
|
|
kind: "request_confirmation",
|
|
title: "Approve the plan",
|
|
summary: "Review and approve the latest plan.",
|
|
status: "pending",
|
|
continuationPolicy: "wake_assignee",
|
|
resolverPolicy: "anyone",
|
|
requestedResolverPolicy: "anyone",
|
|
effectiveResolverPolicy: "anyone",
|
|
resolverPolicyProvenance: "inherited",
|
|
effectiveResolverPolicySource: "requested",
|
|
legacyResolverPolicyAliases: {
|
|
requested: "board_or_agents",
|
|
effective: "board_or_agents",
|
|
},
|
|
createdByAgentId: "agent-1",
|
|
createdByUserId: null,
|
|
resolvedByAgentId: null,
|
|
resolvedByUserId: null,
|
|
createdAt: new Date("2026-04-06T12:01:00.000Z"),
|
|
updatedAt: new Date("2026-04-06T12:01:00.000Z"),
|
|
resolvedAt: null,
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Approve the plan?",
|
|
},
|
|
result: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function interactionItem(
|
|
interaction: IssueThreadInteraction,
|
|
): TaskChatInteractionItem {
|
|
return {
|
|
id: `interaction:${interaction.id}`,
|
|
kind: "interaction",
|
|
interaction,
|
|
};
|
|
}
|
|
|
|
describe("TaskChatInteractionCard", () => {
|
|
let container: HTMLDivElement;
|
|
let root: Root;
|
|
|
|
beforeEach(() => {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
});
|
|
|
|
afterEach(() => {
|
|
flushSync(() => root.unmount());
|
|
container.remove();
|
|
});
|
|
|
|
it("opens the real tool review and reveals the proposal tail before approval", async () => {
|
|
const accept = vi.fn();
|
|
const reject = vi.fn();
|
|
const interaction = structuredClone(pendingToolActionLongProposalInteraction);
|
|
function ReviewSurface() {
|
|
const [reviewing, setReviewing] = useState(false);
|
|
return <TaskChatInteractionCard item={interactionItem(interaction)}
|
|
presentation={reviewing ? "takeover" : "timeline"}
|
|
onReviewRequest={() => setReviewing(true)}
|
|
onAcceptInteraction={accept} onRejectInteraction={reject} />;
|
|
}
|
|
flushSync(() => root.render(<TooltipProvider><ThemeProvider><ReviewSurface /></ThemeProvider></TooltipProvider>));
|
|
expect(container.textContent).not.toContain("November 8, 2030");
|
|
await act(async () => {
|
|
Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Review request")!.click();
|
|
});
|
|
const details = container.querySelector("details")!;
|
|
expect(details).not.toBeNull();
|
|
expect(details.open).toBe(false);
|
|
expect(container.querySelector("pre")?.textContent).toBe(interaction.payload.toolAction!.argumentsSummaryJson);
|
|
expect(details.textContent).toContain("November 8, 2030");
|
|
expect(details.textContent).toContain("[redacted]");
|
|
expect(details.textContent).toContain(interaction.payload.toolAction!.argumentsHash);
|
|
await act(async () => details.querySelector("summary")!.click());
|
|
expect(details.open).toBe(true);
|
|
expect(accept).not.toHaveBeenCalled();
|
|
expect(reject).not.toHaveBeenCalled();
|
|
await act(async () => {
|
|
Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Approve & run")!.click();
|
|
});
|
|
expect(accept).toHaveBeenCalledExactlyOnceWith(interaction, undefined, undefined, false);
|
|
expect(reject).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not render a pending confirmation in the timeline", () => {
|
|
flushSync(() => {
|
|
root.render(
|
|
<TooltipProvider>
|
|
<ThemeProvider>
|
|
<TaskChatInteractionCard
|
|
item={interactionItem(createRequestConfirmation())}
|
|
/>
|
|
</ThemeProvider>
|
|
</TooltipProvider>,
|
|
);
|
|
});
|
|
const card = container.querySelector(
|
|
'[data-testid="task-chat-interaction"]',
|
|
);
|
|
expect(card).toBeNull();
|
|
expect(container.textContent).not.toContain("Requested confirmation");
|
|
expect(container.textContent).not.toContain("waiting for response");
|
|
expect(container.textContent).not.toContain("Approve the plan?");
|
|
});
|
|
|
|
it("renders a plan confirmation as a linked document preview", () => {
|
|
const interaction = createRequestConfirmation({
|
|
sourceRunId: "run-plan",
|
|
title: "Review plan revision 3",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Approve plan revision 3?",
|
|
acceptLabel: "Approve plan",
|
|
target: {
|
|
type: "issue_document",
|
|
issueId: "issue-1",
|
|
documentId: "document-plan",
|
|
key: "plan",
|
|
revisionId: "revision-3",
|
|
revisionNumber: 3,
|
|
label: "Plan v3",
|
|
},
|
|
},
|
|
});
|
|
const planDocument = {
|
|
id: "document-plan",
|
|
issueId: "issue-1",
|
|
key: "plan",
|
|
title: "Plan",
|
|
body: "# Health-check endpoint\n1. Define the health response contract.\n2. Test readiness before and after startup.\n3. Document the verification request.",
|
|
latestRevisionId: "revision-3",
|
|
latestRevisionNumber: 3,
|
|
} as IssueDocument;
|
|
|
|
flushSync(() => {
|
|
root.render(
|
|
<TooltipProvider>
|
|
<ThemeProvider>
|
|
<TaskChatInteractionCard
|
|
item={interactionItem(interaction)}
|
|
planDocument={planDocument}
|
|
/>
|
|
</ThemeProvider>
|
|
</TooltipProvider>,
|
|
);
|
|
});
|
|
|
|
const preview = container.querySelector(
|
|
'[data-testid="plan-review-preview"]',
|
|
);
|
|
expect(preview?.getAttribute("href")).toBe("#document-plan");
|
|
expect(preview?.getAttribute("aria-label")).toBe("Open Plan revision 3");
|
|
expect(container.textContent).toContain("Health-check endpoint");
|
|
expect(container.textContent).toContain(
|
|
"Test readiness before and after startup.",
|
|
);
|
|
expect(container.textContent).not.toContain("Approve plan revision 3?");
|
|
expect(container.textContent).not.toContain("Approve plan");
|
|
expect(container.textContent).not.toContain("Requested plan review");
|
|
});
|
|
|
|
it("renders a runner-owned expired plan review as a receipt without a second preview", () => {
|
|
const interaction = createRequestConfirmation({
|
|
sourceRunId: "run-plan",
|
|
status: "expired",
|
|
title: "Review plan revision 3",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Approve plan revision 3?",
|
|
target: {
|
|
type: "issue_document",
|
|
issueId: "issue-1",
|
|
documentId: "document-plan",
|
|
key: "plan",
|
|
revisionId: "revision-3",
|
|
label: "Plan v3",
|
|
},
|
|
},
|
|
result: {
|
|
version: 1,
|
|
outcome: "superseded_by_comment",
|
|
commentId: "comment-1",
|
|
},
|
|
});
|
|
const planDocument = {
|
|
id: "document-plan",
|
|
issueId: "issue-1",
|
|
key: "plan",
|
|
title: "Plan",
|
|
body: "# Health-check endpoint",
|
|
latestRevisionId: "revision-3",
|
|
latestRevisionNumber: 3,
|
|
} as IssueDocument;
|
|
|
|
flushSync(() => {
|
|
root.render(
|
|
<TooltipProvider>
|
|
<ThemeProvider>
|
|
<TaskChatInteractionCard
|
|
item={interactionItem(interaction)}
|
|
planDocument={planDocument}
|
|
showPlanPreview={false}
|
|
/>
|
|
</ThemeProvider>
|
|
</TooltipProvider>,
|
|
);
|
|
});
|
|
|
|
expect(
|
|
container.querySelector('[data-testid="plan-review-preview"]'),
|
|
).toBeNull();
|
|
expect(
|
|
container.querySelector('[data-testid="task-chat-interaction-receipt"]'),
|
|
).not.toBeNull();
|
|
expect(container.textContent).toContain(
|
|
"Confirmation expired after comment",
|
|
);
|
|
});
|
|
|
|
it("does not put the latest plan body into an older revision receipt", () => {
|
|
const interaction = createRequestConfirmation({
|
|
status: "expired",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Approve plan revision 2?",
|
|
target: {
|
|
type: "issue_document",
|
|
issueId: "issue-1",
|
|
documentId: "document-plan",
|
|
key: "plan",
|
|
revisionId: "revision-2",
|
|
label: "Plan revision 2",
|
|
},
|
|
},
|
|
result: {
|
|
version: 1,
|
|
outcome: "superseded_by_comment",
|
|
commentId: "comment-1",
|
|
},
|
|
});
|
|
const currentPlan = {
|
|
id: "document-plan",
|
|
issueId: "issue-1",
|
|
key: "plan",
|
|
title: "Plan",
|
|
body: "# Revision 3 only\nThis must not appear in the revision 2 receipt.",
|
|
latestRevisionId: "revision-3",
|
|
latestRevisionNumber: 3,
|
|
} as IssueDocument;
|
|
|
|
flushSync(() => {
|
|
root.render(
|
|
<TooltipProvider>
|
|
<ThemeProvider>
|
|
<TaskChatInteractionCard
|
|
item={interactionItem(interaction)}
|
|
planDocument={currentPlan}
|
|
/>
|
|
</ThemeProvider>
|
|
</TooltipProvider>,
|
|
);
|
|
});
|
|
|
|
expect(
|
|
container.querySelector('[data-testid="plan-review-preview"]'),
|
|
).not.toBeNull();
|
|
expect(container.textContent).not.toContain("Revision 3 only");
|
|
expect(container.textContent).toContain(
|
|
"Confirmation expired after comment",
|
|
);
|
|
});
|
|
|
|
it.each([createRequestConfirmation(), pendingRequestCheckboxConfirmationInteraction])(
|
|
"enables $kind acceptance when preparation clears on the same mounted card",
|
|
async (fixture) => {
|
|
const onAcceptInteraction = vi.fn();
|
|
const onRejectInteraction = vi.fn();
|
|
const render = (preparing: boolean) => flushSync(() => root.render(
|
|
<TooltipProvider><ThemeProvider>
|
|
<TaskChatInteractionCard
|
|
item={interactionItem({ ...fixture, acceptanceBlocker: preparing ? "workspace_sync_pending" : undefined })}
|
|
presentation="takeover"
|
|
onAcceptInteraction={onAcceptInteraction}
|
|
onRejectInteraction={onRejectInteraction}
|
|
/>
|
|
</ThemeProvider></TooltipProvider>,
|
|
));
|
|
const approve = () => [...container.querySelectorAll<HTMLButtonElement>("button")].find((button) =>
|
|
button.textContent === (fixture.payload.acceptLabel ?? "Approve"),
|
|
)!;
|
|
render(true);
|
|
expect(container.querySelector('[role="status"]')?.textContent).toContain("Preparing approval…");
|
|
expect(approve().disabled).toBe(true);
|
|
await act(async () => approve().click());
|
|
expect(onAcceptInteraction).not.toHaveBeenCalled();
|
|
expect([...container.querySelectorAll<HTMLButtonElement>("button")].find((button) =>
|
|
button.textContent === (fixture.payload.rejectLabel ?? "Reject"),
|
|
)?.disabled).toBe(false);
|
|
render(false);
|
|
expect(container.textContent).not.toContain("Preparing approval…");
|
|
expect(approve().disabled).toBe(false);
|
|
await act(async () => approve().click());
|
|
expect(onAcceptInteraction).toHaveBeenCalledTimes(1);
|
|
},
|
|
);
|
|
|
|
it("puts the primary CTA at the right edge of the compact action row", () => {
|
|
flushSync(() => {
|
|
root.render(
|
|
<TooltipProvider>
|
|
<ThemeProvider>
|
|
<TaskChatInteractionCard
|
|
item={interactionItem(createRequestConfirmation())}
|
|
presentation="takeover"
|
|
/>
|
|
</ThemeProvider>
|
|
</TooltipProvider>,
|
|
);
|
|
});
|
|
const buttons = Array.from(container.querySelectorAll("button"));
|
|
const approve = buttons.find((button) => button.textContent === "Approve");
|
|
const reject = buttons.find((button) => button.textContent === "Reject");
|
|
expect(approve).not.toBeUndefined();
|
|
expect(reject).not.toBeUndefined();
|
|
const row = approve?.parentElement;
|
|
expect(row).toBe(reject?.parentElement);
|
|
expect(Array.from(row?.querySelectorAll("button") ?? []).at(-1)).toBe(
|
|
approve,
|
|
);
|
|
expect(container.textContent).not.toContain("proposed by");
|
|
expect(container.textContent).not.toContain(
|
|
"Review and approve the latest plan.",
|
|
);
|
|
expect(container.textContent).not.toContain("Needs response");
|
|
expect(container.textContent).not.toContain("Anyone can respond");
|
|
expect(
|
|
container.querySelector('[data-testid="interaction-status-badge"]'),
|
|
).toBeNull();
|
|
});
|
|
|
|
it.each([
|
|
["confirmation", "pending"],
|
|
["confirmation", "accepted"],
|
|
["checkbox", "pending"],
|
|
["checkbox", "accepted"],
|
|
] as const)("renders Markdown in %s prompts when %s", (kind, status) => {
|
|
const interaction = structuredClone(kind === "confirmation"
|
|
? createRequestConfirmation()
|
|
: pendingRequestCheckboxConfirmationInteraction);
|
|
interaction.status = status;
|
|
interaction.payload.prompt = "Review the [two screenshots](/PAP/issues/PAP-42#document-preview).\n\nSelect **Accept** to keep this design.\n\n- Check `preview.png`\n- Keep the layout\n\n\n\n```mermaid\nflowchart LR\n A --> B\n```";
|
|
if (status === "accepted") interaction.result = { version: 1, outcome: "accepted" };
|
|
const accept = vi.fn();
|
|
const reject = vi.fn();
|
|
|
|
flushSync(() => root.render(
|
|
<QueryClientProvider client={new QueryClient()}><TooltipProvider><ThemeProvider>
|
|
<TaskChatInteractionCard item={interactionItem(interaction)}
|
|
presentation={status === "pending" ? "takeover" : "timeline"}
|
|
onAcceptInteraction={accept} onRejectInteraction={reject} />
|
|
</ThemeProvider></TooltipProvider></QueryClientProvider>,
|
|
));
|
|
|
|
const link = container.querySelector('a[href="/issues/PAP-42#document-preview"]');
|
|
expect(link?.textContent).toBe("two screenshots");
|
|
const prompt = link?.closest(".paperclip-markdown");
|
|
expect(prompt?.querySelector("strong")?.textContent).toBe("Accept");
|
|
expect(prompt?.querySelector("code")?.textContent).toBe("preview.png");
|
|
expect(prompt?.querySelector("img")).toBeNull();
|
|
expect(prompt?.querySelectorAll("p")).toHaveLength(3);
|
|
expect(prompt?.querySelector("[data-markdown-image-reference]")?.textContent)
|
|
.toContain("Image: Review image (https://provider.invalid/review.png)");
|
|
expect(prompt?.querySelector("pre code.language-mermaid")?.textContent)
|
|
.toContain("flowchart LR");
|
|
expect(prompt?.querySelector(".paperclip-mermaid")).toBeNull();
|
|
expect(prompt?.querySelectorAll("li")).toHaveLength(2);
|
|
expect(prompt?.textContent).not.toContain("[two screenshots]");
|
|
expect(accept).not.toHaveBeenCalled();
|
|
expect(reject).not.toHaveBeenCalled();
|
|
if (kind === "checkbox" && status === "pending") {
|
|
const group = container.querySelector('[role="group"]');
|
|
const label = document.getElementById(group?.getAttribute("aria-labelledby") ?? "");
|
|
expect(label?.contains(prompt ?? null)).toBe(true);
|
|
expect(label?.textContent).toContain("two screenshots");
|
|
expect(label?.textContent).not.toContain("[two screenshots]");
|
|
expect(label?.textContent).not.toContain("**Accept**");
|
|
expect(group?.getAttribute("aria-label")).toBeNull();
|
|
}
|
|
});
|
|
|
|
it("stacks the confirmation prompt full-width and hides unlabeled custom target keys", () => {
|
|
const render = (target: RequestConfirmationInteraction["payload"]["target"]) =>
|
|
flushSync(() => {
|
|
root.render(
|
|
<TooltipProvider>
|
|
<ThemeProvider>
|
|
<TaskChatInteractionCard
|
|
item={interactionItem(
|
|
createRequestConfirmation({
|
|
payload: {
|
|
version: 1,
|
|
prompt: "The change is open as PR #16.\n\nMay the PR merge?",
|
|
target,
|
|
},
|
|
}),
|
|
)}
|
|
presentation="takeover"
|
|
/>
|
|
</ThemeProvider>
|
|
</TooltipProvider>,
|
|
);
|
|
});
|
|
|
|
render({ type: "custom", key: "github:paperclipai/paperclip-desktop:pull:16" });
|
|
expect(container.textContent).not.toContain("github:paperclipai");
|
|
const prompt = Array.from(container.querySelectorAll("p")).find((node) =>
|
|
node.textContent?.startsWith("The change is open"),
|
|
);
|
|
expect(prompt?.className).not.toContain("flex-1");
|
|
expect(prompt?.closest(".paperclip-markdown")?.querySelectorAll("p")).toHaveLength(2);
|
|
expect(prompt?.parentElement?.className).not.toContain("justify-between");
|
|
|
|
render({
|
|
type: "custom",
|
|
key: "github:paperclipai/paperclip-desktop:pull:16",
|
|
label: "PR #16",
|
|
href: "https://github.com/paperclipai/paperclip-desktop/pull/16",
|
|
});
|
|
const link = container.querySelector(
|
|
'a[href="https://github.com/paperclipai/paperclip-desktop/pull/16"]',
|
|
);
|
|
expect(link?.textContent).toBe("PR #16");
|
|
expect(container.textContent).not.toContain("github:paperclipai");
|
|
});
|
|
|
|
it("stacks the checkbox confirmation prompt full-width and hides unlabeled custom target keys", () => {
|
|
const render = (target: RequestConfirmationInteraction["payload"]["target"]) =>
|
|
flushSync(() => {
|
|
root.render(
|
|
<TooltipProvider>
|
|
<ThemeProvider>
|
|
<TaskChatInteractionCard
|
|
item={interactionItem({
|
|
...pendingRequestCheckboxConfirmationInteraction,
|
|
payload: {
|
|
...pendingRequestCheckboxConfirmationInteraction.payload,
|
|
prompt: "Pick the drafts to delete.\n\nUnchecked drafts stay.",
|
|
target,
|
|
},
|
|
})}
|
|
presentation="takeover"
|
|
/>
|
|
</ThemeProvider>
|
|
</TooltipProvider>,
|
|
);
|
|
});
|
|
|
|
render({ type: "custom", key: "github:paperclipai/paperclip-desktop:pull:16" });
|
|
expect(container.textContent).not.toContain("github:paperclipai");
|
|
const prompt = Array.from(container.querySelectorAll("p")).find((node) =>
|
|
node.textContent?.startsWith("Pick the drafts"),
|
|
);
|
|
expect(prompt?.className).not.toContain("flex-1");
|
|
expect(prompt?.closest(".paperclip-markdown")?.querySelectorAll("p")).toHaveLength(2);
|
|
expect(prompt?.parentElement?.className).not.toContain("justify-between");
|
|
|
|
render({
|
|
type: "custom",
|
|
key: "github:paperclipai/paperclip-desktop:pull:16",
|
|
label: "PR #16",
|
|
href: "https://github.com/paperclipai/paperclip-desktop/pull/16",
|
|
});
|
|
const link = container.querySelector(
|
|
'a[href="https://github.com/paperclipai/paperclip-desktop/pull/16"]',
|
|
);
|
|
expect(link?.textContent).toBe("PR #16");
|
|
expect(container.textContent).not.toContain("github:paperclipai");
|
|
});
|
|
|
|
it("keeps the plan revision in the header and hides the prompt while requesting changes", async () => {
|
|
const interaction = createRequestConfirmation({
|
|
sourceRunId: "run-plan",
|
|
title: "Review the proposed plan",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Do you accept this plan?",
|
|
acceptLabel: "Approve plan",
|
|
rejectLabel: "Request changes",
|
|
rejectRequiresReason: true,
|
|
rejectReasonLabel: "What should change?",
|
|
target: {
|
|
type: "issue_document",
|
|
issueId: "issue-1",
|
|
documentId: "document-plan",
|
|
key: "plan",
|
|
revisionId: "revision-4",
|
|
revisionNumber: 4,
|
|
},
|
|
},
|
|
});
|
|
|
|
flushSync(() => {
|
|
root.render(
|
|
<TooltipProvider>
|
|
<ThemeProvider>
|
|
<TaskChatInteractionCard
|
|
item={interactionItem(interaction)}
|
|
presentation="takeover"
|
|
onAcceptInteraction={vi.fn()}
|
|
onRejectInteraction={vi.fn()}
|
|
onUploadImage={vi.fn().mockResolvedValue("/uploads/boat.png")}
|
|
/>
|
|
</ThemeProvider>
|
|
</TooltipProvider>,
|
|
);
|
|
});
|
|
|
|
const title = Array.from(container.querySelectorAll("strong")).find(
|
|
(candidate) => candidate.textContent === "Review the proposed plan",
|
|
);
|
|
const revision = Array.from(container.querySelectorAll("span")).find(
|
|
(candidate) => candidate.textContent === "plan · v4",
|
|
);
|
|
expect(title?.parentElement).toBe(revision?.parentElement);
|
|
expect(container.textContent).toContain("Do you accept this plan?");
|
|
expect(container.querySelectorAll(".border-t")).toHaveLength(0);
|
|
|
|
const requestChanges = Array.from(
|
|
container.querySelectorAll<HTMLButtonElement>("button"),
|
|
).find((button) => button.textContent?.trim() === "Request changes");
|
|
flushSync(() => requestChanges?.click());
|
|
|
|
expect(container.textContent).not.toContain("Do you accept this plan?");
|
|
expect(container.textContent).toContain("What should change?");
|
|
expect(container.textContent).toContain("plan · v4");
|
|
expect(
|
|
container.querySelector('[data-testid="plan-revision-composer"]'),
|
|
).not.toBeNull();
|
|
expect(
|
|
container
|
|
.querySelector('[data-testid="mock-markdown-editor"]')
|
|
?.getAttribute("data-image-upload"),
|
|
).toBe("true");
|
|
expect(container.textContent).not.toContain("Attach image");
|
|
expect(container.textContent).not.toContain("drop/paste an image");
|
|
const reasonGroup = container.querySelector(
|
|
'[data-testid="plan-revision-composer"]',
|
|
)?.parentElement;
|
|
expect(reasonGroup?.className).toContain("space-y-2");
|
|
expect(reasonGroup?.className).not.toContain("mt-3");
|
|
});
|
|
|
|
it("shows one question at a time and preserves answers across pages", async () => {
|
|
const submit = vi.fn();
|
|
flushSync(() => {
|
|
root.render(
|
|
<TooltipProvider>
|
|
<ThemeProvider>
|
|
<TaskChatInteractionCard
|
|
item={interactionItem(pendingAskUserQuestionsInteraction)}
|
|
presentation="takeover"
|
|
onSubmitInteractionAnswers={submit}
|
|
/>
|
|
</ThemeProvider>
|
|
</TooltipProvider>,
|
|
);
|
|
});
|
|
|
|
expect(container.textContent).toContain("1 of 2");
|
|
expect(container.textContent).not.toContain("Choose one");
|
|
expect(container.textContent).toContain("How aggressive should");
|
|
expect(container.textContent).not.toContain(
|
|
"What should the answered-state card emphasize",
|
|
);
|
|
|
|
const firstAnswer = Array.from(container.querySelectorAll("button")).find(
|
|
(button) =>
|
|
button.textContent?.includes("Only collapse hidden descendants"),
|
|
);
|
|
await act(async () => firstAnswer?.click());
|
|
expect(submit).not.toHaveBeenCalled();
|
|
// Answering stays on the question; Next is what moves on.
|
|
expect(container.textContent).toContain("1 of 2");
|
|
await act(async () =>
|
|
Array.from(container.querySelectorAll<HTMLButtonElement>("button"))
|
|
.find((button) => button.textContent?.trim() === "Next")
|
|
?.click(),
|
|
);
|
|
|
|
expect(container.textContent).toContain("2 of 2");
|
|
expect(container.textContent).toContain(
|
|
"What should the answered-state card emphasize",
|
|
);
|
|
expect(container.textContent).not.toContain("How aggressive should");
|
|
|
|
const secondAnswer = Array.from(container.querySelectorAll("button")).find(
|
|
(button) => button.textContent?.includes("Inline answer pills"),
|
|
);
|
|
await act(async () => secondAnswer?.click());
|
|
const send = Array.from(container.querySelectorAll("button")).find(
|
|
(button) => button.textContent?.trim() === "Send answers",
|
|
);
|
|
await act(async () => send?.click());
|
|
|
|
expect(submit).toHaveBeenCalledOnce();
|
|
expect(submit.mock.calls[0]?.[1]).toEqual([
|
|
{ questionId: "collapse-depth", optionIds: ["visible-root"] },
|
|
{ questionId: "post-submit-summary", optionIds: ["answers-inline"] },
|
|
]);
|
|
});
|
|
|
|
it("collapses answered questions to one expandable timeline row", () => {
|
|
const answered = structuredClone(pendingAskUserQuestionsInteraction);
|
|
answered.status = "answered";
|
|
answered.resolvedAt = new Date("2026-08-24T13:30:00.000Z");
|
|
answered.result = {
|
|
version: 1,
|
|
answers: [{ questionId: "collapse-depth", optionIds: ["visible-root"] }],
|
|
};
|
|
flushSync(() => {
|
|
root.render(
|
|
<TooltipProvider>
|
|
<ThemeProvider>
|
|
<TaskChatInteractionCard item={interactionItem(answered)} />
|
|
</ThemeProvider>
|
|
</TooltipProvider>,
|
|
);
|
|
});
|
|
|
|
const receipt = container.querySelector<HTMLDetailsElement>(
|
|
'[data-testid="task-chat-answered-questions-receipt"]',
|
|
);
|
|
expect(receipt?.open).toBe(false);
|
|
expect(receipt?.querySelector("summary")?.textContent).toBe(
|
|
"Questions answered",
|
|
);
|
|
expect(receipt?.querySelectorAll("summary svg")).toHaveLength(2);
|
|
expect(receipt?.className).not.toContain("border");
|
|
expect(receipt?.className).not.toContain("bg-card");
|
|
expect(receipt?.querySelector("summary")?.className).toContain("px-1");
|
|
expect(receipt?.querySelector("summary")?.className).toContain(
|
|
"text-muted-foreground",
|
|
);
|
|
expect(receipt?.querySelector("summary")?.className).toContain("leading-5");
|
|
expect(receipt?.querySelector("summary span")?.className).not.toContain(
|
|
"font-medium",
|
|
);
|
|
expect(
|
|
receipt
|
|
?.querySelector('[data-testid="task-chat-interaction-receipt-icon"]')
|
|
?.classList.contains("h-3.5"),
|
|
).toBe(true);
|
|
const iconSlot = receipt?.querySelector(
|
|
'[data-testid="task-chat-interaction-receipt-icon-slot"]',
|
|
);
|
|
expect(iconSlot?.classList.contains("w-6")).toBe(true);
|
|
expect(iconSlot?.classList.contains("items-center")).toBe(true);
|
|
expect(iconSlot?.classList.contains("justify-center")).toBe(true);
|
|
const caret = receipt?.querySelector(
|
|
'[data-testid="task-chat-interaction-receipt-caret"]',
|
|
);
|
|
expect(caret?.classList.contains("h-3")).toBe(true);
|
|
expect(caret?.classList.contains("opacity-0")).toBe(true);
|
|
expect(caret?.getAttribute("class")).toContain("group-hover:opacity-70");
|
|
expect(container.textContent).not.toContain("Answered 2 questions");
|
|
expect(container.textContent).not.toContain(
|
|
"View original request and resolution",
|
|
);
|
|
expect(container.textContent).not.toContain("Options:");
|
|
expect(container.textContent).not.toContain("Asked by:");
|
|
expect(container.textContent).not.toContain("Resolved by:");
|
|
});
|
|
|
|
it("collapses resolved confirmations and selections to one borderless row", () => {
|
|
const acceptedConfirmation = createRequestConfirmation({
|
|
status: "accepted",
|
|
resolvedAt: new Date("2026-08-24T13:30:00.000Z"),
|
|
result: { version: 1, outcome: "accepted" },
|
|
});
|
|
const continuedConfirmation = createRequestConfirmation({
|
|
id: "confirmation-continued",
|
|
status: "rejected",
|
|
resolvedAt: new Date("2026-08-24T13:30:30.000Z"),
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Is this task ready to complete?",
|
|
rejectLabel: "Continue work",
|
|
},
|
|
result: { version: 1, outcome: "rejected" },
|
|
});
|
|
const acceptedSelection = structuredClone(
|
|
pendingRequestCheckboxConfirmationInteraction,
|
|
);
|
|
acceptedSelection.status = "accepted";
|
|
acceptedSelection.resolvedAt = new Date("2026-08-24T13:31:00.000Z");
|
|
acceptedSelection.result = {
|
|
version: 1,
|
|
outcome: "accepted",
|
|
selectedOptionIds: [],
|
|
};
|
|
|
|
flushSync(() => {
|
|
root.render(
|
|
<TooltipProvider>
|
|
<ThemeProvider>
|
|
<>
|
|
<TaskChatInteractionCard
|
|
item={interactionItem(acceptedConfirmation)}
|
|
/>
|
|
<TaskChatInteractionCard
|
|
item={interactionItem(continuedConfirmation)}
|
|
/>
|
|
<TaskChatInteractionCard
|
|
item={interactionItem(acceptedSelection)}
|
|
/>
|
|
</>
|
|
</ThemeProvider>
|
|
</TooltipProvider>,
|
|
);
|
|
});
|
|
|
|
const receipts = container.querySelectorAll<HTMLDetailsElement>(
|
|
'[data-testid="task-chat-interaction-receipt"]',
|
|
);
|
|
expect(receipts).toHaveLength(3);
|
|
expect(receipts[0]?.querySelector("summary")?.textContent).toBe(
|
|
"Confirmed request",
|
|
);
|
|
expect(receipts[1]?.querySelector("summary")?.textContent).toBe(
|
|
"Selected “Continue work”",
|
|
);
|
|
expect(receipts[2]?.querySelector("summary")?.textContent).toBe(
|
|
"Confirmed with no options selected",
|
|
);
|
|
for (const receipt of receipts) {
|
|
expect(receipt.open).toBe(false);
|
|
expect(receipt.className).not.toContain("border");
|
|
expect(receipt.className).not.toContain("bg-card");
|
|
}
|
|
expect(container.textContent).not.toContain("Asked by:");
|
|
expect(container.textContent).not.toContain("Outcome:");
|
|
});
|
|
|
|
it("reuses the question form for a recovered harness text request", async () => {
|
|
const submit = vi.fn();
|
|
const recovered = structuredClone(pendingAskUserQuestionsInteraction);
|
|
recovered.id = "recovered-runtime-question";
|
|
recovered.title = "Configure deployment";
|
|
recovered.payload = {
|
|
version: 1,
|
|
supersedeOnUserComment: false,
|
|
submitLabel: "Continue",
|
|
questions: [
|
|
{
|
|
id: "replicas",
|
|
prompt: "How many replicas?",
|
|
selectionMode: "single",
|
|
required: true,
|
|
options: [
|
|
{
|
|
id: "__paperclip_text__",
|
|
label: "Enter an integer",
|
|
freeText: true,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
questionSet: {
|
|
schema: "paperclip.question_set.v1",
|
|
title: "Configure deployment",
|
|
description: "The provider disconnected while waiting for this answer.",
|
|
submitLabel: "Continue",
|
|
questions: [
|
|
{
|
|
id: "replicas",
|
|
header: "Replicas",
|
|
prompt: "How many replicas?",
|
|
required: true,
|
|
answerMode: "text",
|
|
textValidation: { inputType: "integer", minimum: 1, maximum: 10 },
|
|
},
|
|
],
|
|
},
|
|
};
|
|
flushSync(() => {
|
|
root.render(
|
|
<TooltipProvider>
|
|
<ThemeProvider>
|
|
<TaskChatInteractionCard
|
|
item={interactionItem(recovered)}
|
|
presentation="takeover"
|
|
onSubmitInteractionAnswers={submit}
|
|
/>
|
|
</ThemeProvider>
|
|
</TooltipProvider>,
|
|
);
|
|
});
|
|
|
|
expect(container.textContent).toContain(
|
|
"The provider disconnected while waiting for this answer.",
|
|
);
|
|
expect(container.textContent).toContain("Write an answer");
|
|
expect(container.textContent).not.toContain("Required");
|
|
const textarea = container.querySelector("textarea")!;
|
|
await act(async () => {
|
|
const setter = Object.getOwnPropertyDescriptor(
|
|
HTMLTextAreaElement.prototype,
|
|
"value",
|
|
)?.set;
|
|
setter?.call(textarea, "3");
|
|
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
|
});
|
|
const send = Array.from(container.querySelectorAll("button")).find(
|
|
(button) => button.textContent?.trim() === "Continue",
|
|
);
|
|
await act(async () => send?.click());
|
|
|
|
expect(submit).toHaveBeenCalledWith(recovered, [
|
|
{
|
|
questionId: "replicas",
|
|
optionIds: [],
|
|
otherText: "3",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it.each(["canonical_text", "canonical_custom", "legacy_custom"])("preserves the %s answer mapping", async (mode) => {
|
|
const submit = vi.fn();
|
|
const interaction = structuredClone(pendingAskUserQuestionsInteraction);
|
|
interaction.payload.questions = [{
|
|
id: "draft", prompt: "Edit", selectionMode: "single", required: true,
|
|
options: [{ id: "preset", label: "Preset" }, { id: "other", label: "Custom answer", freeText: true }],
|
|
}];
|
|
interaction.payload.questionSet = mode === "legacy_custom" ? undefined : {
|
|
schema: "paperclip.question_set.v1",
|
|
questions: [mode === "canonical_text"
|
|
? { id: "draft", prompt: "Edit", required: true, answerMode: "text", initialText: "Provider draft" }
|
|
: { id: "draft", prompt: "Edit", required: true, answerMode: "single_select", options: [{ id: "preset", label: "Preset" }], customAnswer: { enabled: true, label: "Custom answer" } }],
|
|
};
|
|
await act(async () => root.render(
|
|
<TooltipProvider><ThemeProvider>
|
|
<TaskChatInteractionCard item={interactionItem(interaction)} presentation="takeover" onSubmitInteractionAnswers={submit} />
|
|
</ThemeProvider></TooltipProvider>,
|
|
));
|
|
if (mode !== "canonical_text") {
|
|
await act(async () => Array.from(container.querySelectorAll("button")).find(button => button.textContent?.includes("Custom answer"))!.click());
|
|
}
|
|
const edited = "\n Operator 漢字 edit\nLiteral \\n stays literal. \n";
|
|
await act(async () => {
|
|
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!.call(container.querySelector("textarea")!, edited);
|
|
container.querySelector("textarea")!.dispatchEvent(new Event("input", { bubbles: true }));
|
|
});
|
|
await act(async () => Array.from(container.querySelectorAll("button")).find(button => button.textContent?.trim() === "Send answers" || button.textContent?.trim() === "Submit answers")!.click());
|
|
expect(submit).toHaveBeenCalledExactlyOnceWith(interaction, [{
|
|
questionId: "draft", optionIds: [], otherText: mode === "canonical_text" ? edited : edited.trim(),
|
|
}]);
|
|
});
|
|
|
|
it("opens a compact attachment item through the authenticated file endpoint", () => {
|
|
const interaction = { ...pendingRequestItemVerdictsInteraction, payload: { ...pendingRequestItemVerdictsInteraction.payload,
|
|
items: [{ id: "file", label: "Reviewed file", target: { type: "issue_attachment" as const, attachmentId: "immutable-file", assetId: "asset", sha256: "hash" } }],
|
|
} };
|
|
flushSync(() => root.render(<TooltipProvider><ThemeProvider><TaskChatInteractionCard item={interactionItem(interaction)} presentation="takeover" onSubmitInteractionVerdicts={() => undefined} /></ThemeProvider></TooltipProvider>));
|
|
expect(container.querySelector('a[href="/api/attachments/immutable-file/content"]')?.getAttribute("target")).toBe("_blank");
|
|
});
|
|
|
|
it.each([undefined, "/stale-display-link"])("opens a compact confirmation's bound attachment when href is %s", href => {
|
|
const interaction = createRequestConfirmation({ payload: { version: 1, prompt: "Review the attached copy", target: {
|
|
type: "issue_attachment", attachmentId: "immutable-file", assetId: "asset", sha256: "hash", href,
|
|
} } });
|
|
flushSync(() => root.render(<TooltipProvider><ThemeProvider><TaskChatInteractionCard item={interactionItem(interaction)} presentation="takeover" onAcceptInteraction={() => undefined} /></ThemeProvider></TooltipProvider>));
|
|
expect(container.querySelector('a[href="/api/attachments/immutable-file/content"]')?.getAttribute("target")).toBe("_blank");
|
|
});
|
|
|
|
it("opens the bound output revision from a compact review item", () => {
|
|
const interaction = { ...pendingRequestItemVerdictsInteraction, payload: { ...pendingRequestItemVerdictsInteraction.payload,
|
|
items: [{ id: "copy", label: "Reviewed copy", target: { type: "issue_document" as const, issueId: "owner", key: "copy", revisionId: "reviewed-v1" } }],
|
|
} };
|
|
flushSync(() => root.render(<TooltipProvider><ThemeProvider><TaskChatInteractionCard item={interactionItem(interaction)} presentation="takeover" onSubmitInteractionVerdicts={() => undefined} /></ThemeProvider></TooltipProvider>));
|
|
expect(container.querySelector('a[href*="document-copy&revision=reviewed-v1"]')?.getAttribute("href"))
|
|
.toBe(`/issues/owner#document-copy&revision=reviewed-v1&interaction=${interaction.id}`);
|
|
});
|
|
|
|
it("paginates item verdicts instead of expanding the whole review set", async () => {
|
|
flushSync(() => {
|
|
root.render(
|
|
<TooltipProvider>
|
|
<ThemeProvider>
|
|
<TaskChatInteractionCard
|
|
item={interactionItem(pendingRequestItemVerdictsInteraction)}
|
|
presentation="takeover"
|
|
onSubmitInteractionVerdicts={() => undefined}
|
|
/>
|
|
</ThemeProvider>
|
|
</TooltipProvider>,
|
|
);
|
|
});
|
|
|
|
expect(container.textContent).toContain("1 of 5");
|
|
expect(container.textContent).toContain("Spring launch recap");
|
|
expect(container.textContent).not.toContain("Monthly changelog digest");
|
|
expect(
|
|
container.querySelector('[aria-label="Item pagination"]'),
|
|
).not.toBeNull();
|
|
|
|
const approve = Array.from(container.querySelectorAll("button")).find(
|
|
(button) => button.textContent?.trim() === "approve",
|
|
);
|
|
const rejectOnFirstItem = Array.from(
|
|
container.querySelectorAll("button"),
|
|
).find((button) => button.textContent?.trim() === "reject");
|
|
expect(approve?.className).toContain("text-emerald-700");
|
|
expect(rejectOnFirstItem?.className).toContain("text-destructive");
|
|
expect(approve?.querySelector("svg")).not.toBeNull();
|
|
expect(rejectOnFirstItem?.querySelector("svg")).not.toBeNull();
|
|
await act(async () => approve?.click());
|
|
|
|
expect(container.textContent).toContain("2 of 5");
|
|
expect(container.textContent).toContain("Monthly changelog digest");
|
|
expect(container.textContent).not.toContain("Spring launch recap");
|
|
|
|
const reject = Array.from(container.querySelectorAll("button")).find(
|
|
(button) => button.textContent?.trim() === "reject",
|
|
);
|
|
await act(async () => reject?.click());
|
|
expect(container.textContent).toContain("Why reject?");
|
|
expect(container.textContent).toContain("Submit rejection reason");
|
|
expect(container.textContent).toContain("2 of 5");
|
|
expect(
|
|
container.querySelector<HTMLButtonElement>('[aria-label="Next item"]')
|
|
?.disabled,
|
|
).toBe(true);
|
|
|
|
const textarea = container.querySelector("textarea")!;
|
|
await act(async () => {
|
|
const setter = Object.getOwnPropertyDescriptor(
|
|
HTMLTextAreaElement.prototype,
|
|
"value",
|
|
)?.set;
|
|
setter?.call(textarea, "Needs a warmer introduction.");
|
|
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
|
});
|
|
expect(
|
|
container.querySelector<HTMLButtonElement>('[aria-label="Next item"]')
|
|
?.disabled,
|
|
).toBe(true);
|
|
const submitReason = Array.from(container.querySelectorAll("button")).find(
|
|
(button) => button.textContent?.trim() === "Submit rejection reason",
|
|
);
|
|
await act(async () => submitReason?.click());
|
|
expect(container.textContent).toContain("3 of 5");
|
|
expect(container.textContent).toContain("Founder's note on reliability");
|
|
});
|
|
|
|
it("collapses an expired confirmation receipt at the request position", () => {
|
|
flushSync(() => {
|
|
root.render(
|
|
<TooltipProvider>
|
|
<ThemeProvider>
|
|
<TaskChatInteractionCard
|
|
item={interactionItem(
|
|
createRequestConfirmation({ status: "expired" }),
|
|
)}
|
|
/>
|
|
</ThemeProvider>
|
|
</TooltipProvider>,
|
|
);
|
|
});
|
|
const receipt = container.querySelector<HTMLDetailsElement>(
|
|
'[data-testid="task-chat-interaction-receipt"]',
|
|
);
|
|
expect(receipt).not.toBeNull();
|
|
expect(receipt?.open).toBe(false);
|
|
expect(container.textContent).toContain("Approve the plan");
|
|
expect(container.textContent).toContain("expired");
|
|
expect(container.textContent).toContain("Approve the plan?");
|
|
expect(receipt?.querySelector("summary")?.textContent).toBe(
|
|
"Confirmation expired",
|
|
);
|
|
expect(container.textContent).not.toContain(
|
|
"View original request and resolution",
|
|
);
|
|
});
|
|
|
|
it("keeps security details inside an expandable expired receipt", () => {
|
|
flushSync(() => {
|
|
root.render(
|
|
<TooltipProvider>
|
|
<ThemeProvider>
|
|
<TaskChatInteractionCard
|
|
item={interactionItem(expiredSecretProposalInteraction)}
|
|
/>
|
|
</ThemeProvider>
|
|
</TooltipProvider>,
|
|
);
|
|
});
|
|
|
|
expect(
|
|
container.querySelector('[data-testid="task-chat-interaction-receipt"]'),
|
|
).not.toBeNull();
|
|
expect(container.textContent).toContain("Confirmation expired");
|
|
expect(container.textContent).toContain("OpenAI API key");
|
|
expect(container.textContent).toContain("access.evals_openai_api_key");
|
|
expect(container.textContent).toContain("EvalsEngineer");
|
|
expect(container.textContent).not.toContain("Asked by:");
|
|
expect(container.textContent).not.toContain("Resolved:");
|
|
expect(container.textContent).not.toContain("Outcome:");
|
|
});
|
|
|
|
it("keeps skipped confirmation context, actor metadata, and terminal reason expandable", () => {
|
|
const skipped = createRequestConfirmation({
|
|
status: "cancelled",
|
|
resolvedByUserId: "user-1",
|
|
resolvedAt: new Date("2026-04-06T12:05:00.000Z"),
|
|
result: {
|
|
version: 1,
|
|
outcome: "skipped",
|
|
reason: "I want to write a normal message.",
|
|
},
|
|
});
|
|
flushSync(() => {
|
|
root.render(
|
|
<TooltipProvider>
|
|
<ThemeProvider>
|
|
<TaskChatInteractionCard
|
|
item={interactionItem(skipped)}
|
|
agentMap={
|
|
new Map([
|
|
["agent-1", { id: "agent-1", name: "Planner" }],
|
|
]) as never
|
|
}
|
|
userLabelMap={new Map([["user-1", "Riley"]])}
|
|
/>
|
|
</ThemeProvider>
|
|
</TooltipProvider>,
|
|
);
|
|
});
|
|
|
|
const receipt = container.querySelector<HTMLDetailsElement>(
|
|
'[data-testid="task-chat-interaction-receipt"]',
|
|
);
|
|
expect(receipt?.open).toBe(false);
|
|
expect(receipt?.querySelector("summary")?.textContent).toBe(
|
|
"Skipped interaction",
|
|
);
|
|
expect(container.textContent).toContain("Approve the plan?");
|
|
expect(container.textContent).not.toContain("Asked by:");
|
|
expect(container.textContent).not.toContain("Resolved by:");
|
|
expect(container.textContent).not.toContain("Outcome:");
|
|
expect(container.textContent).toContain(
|
|
"I want to write a normal message.",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("TaskChatThreadView interaction items", () => {
|
|
let container: HTMLDivElement;
|
|
let root: Root;
|
|
|
|
beforeEach(() => {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
});
|
|
|
|
afterEach(() => {
|
|
flushSync(() => root.unmount());
|
|
container.remove();
|
|
});
|
|
|
|
it("renders interaction items through renderInteraction", () => {
|
|
const item = interactionItem(
|
|
createRequestConfirmation({
|
|
status: "accepted",
|
|
result: { version: 1, outcome: "accepted" },
|
|
}),
|
|
);
|
|
flushSync(() => {
|
|
root.render(
|
|
<TooltipProvider>
|
|
<ThemeProvider>
|
|
<TaskChatThreadView
|
|
items={[
|
|
{
|
|
id: "turn-boundary",
|
|
kind: "marker",
|
|
variant: "turn_boundary",
|
|
label: "Earlier activity",
|
|
},
|
|
item,
|
|
]}
|
|
scroll={false}
|
|
renderInteraction={(it) => <TaskChatInteractionCard item={it} />}
|
|
/>
|
|
</ThemeProvider>
|
|
</TooltipProvider>,
|
|
);
|
|
});
|
|
expect(
|
|
container.querySelector('[data-testid="task-chat-interaction"]'),
|
|
).not.toBeNull();
|
|
expect(
|
|
container
|
|
.querySelector('[data-testid="task-chat-interaction"]')
|
|
?.parentElement?.classList.contains("mt-3"),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("renders nothing for interaction items without renderInteraction", () => {
|
|
const item = interactionItem(createRequestConfirmation());
|
|
flushSync(() => {
|
|
root.render(
|
|
<TooltipProvider>
|
|
<ThemeProvider>
|
|
<TaskChatThreadView items={[item]} scroll={false} />
|
|
</ThemeProvider>
|
|
</TooltipProvider>,
|
|
);
|
|
});
|
|
expect(
|
|
container.querySelector('[data-testid="task-chat-interaction"]'),
|
|
).toBeNull();
|
|
});
|
|
});
|