mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-16 05:17:41 +02:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents ask the board for decisions with `request_confirmation`
interaction cards
> - The compact task-chat card puts the prompt and the target pill in
one `justify-between` flex row
> - An agent can send a `custom` target with only a machine `key` (for
example `github:org/repo:pull:16`) and no `label`
> - The card shows that raw key as a mono pill that does not shrink, so
the card splits about 50/50 and the prompt is squeezed into the left
half
> - This pull request stacks the prompt above the pill and hides custom
targets that have no human label
> - The benefit is a readable, full-width confirmation prompt without
meaningless identifier pills
## Linked Issues or Issue Description
**What happened?**
A pending confirmation card split its body into two columns. The prompt
used the left half. A pill with the raw text
`github:paperclipai/paperclip-desktop:pull:16` used the right half. The
line breaks in the prompt were also lost.
**Steps to reproduce**
1. Create a `request_confirmation` interaction with a multi-line
`prompt` and `target: { "type": "custom", "key":
"github:org/repo:pull:16" }` (no `label`).
2. Open the task chat for the issue.
3. See that the prompt and the key pill share one row, split about
50/50.
**Expected behavior**
The prompt uses the full card width and keeps its line breaks. A custom
target key is an identifier for idempotency and stale detection, so it
does not show as display text. A target pill shows only when the target
has a human label.
## What Changed
- `TaskChatCompactInteractionCard`: stack the prompt full width with the
target pill below it. This applies to the confirmation and the
checkbox-confirmation cards.
- `TaskChatCompactInteractionCard`: keep line breaks in the prompt
(`whitespace-pre-line`, `break-words`).
- `TaskChatCompactInteractionCard` and `IssueThreadInteractionCard`:
show no target chip for a `custom` target without a `label`. Labeled
custom targets (for example connection authorization) and
`issue_document` targets do not change. The stale-target chevron shows
only when both chips show.
- Add regression tests for the stacked layout, the hidden unlabeled key,
and the labeled link pill. The tests cover the compact confirmation and
checkbox-confirmation cards, and the thread card pending chip and
stale-target expiry (with labeled, unlabeled, and mixed custom targets,
and the chevron rule).
- `CONTRIBUTING.md`: add a "Keep Screenshots Out of the Repo" section.
Contributors attach review screenshots to the PR on GitHub. They do not
commit them to the repository.
## Verification
- `cd ui && npx vitest run
src/components/task-chat/TaskChatInteractionCard.test.tsx
src/components/IssueThreadInteractionCard.test.tsx`: 2 files, 100 tests
pass. If you revert the `isDisplayableRequestConfirmationTarget` guard,
3 of the new thread-card tests fail.
- `node scripts/check-token-gates.mjs`: all gates clean.
- `pnpm --filter @paperclipai/ui typecheck`: no errors in changed files.
The only errors come from `@paperclipai/plugin-sdk/ui`, which had no
built `dist` in the local workspace. These errors are not related to
this change.
- Manual: create a confirmation as in the steps above. The prompt is
full width and no key pill shows. Add `label: "PR #16"` and an `href`. A
`PR #16` link pill shows below the prompt.
### Screenshots
These are Storybook renders of the compact task-chat card at 720 px. The
three cards are: a confirmation with an unlabeled custom target (the
reported case), a confirmation with a labeled custom target, and a
checkbox confirmation with an unlabeled custom target.
The images load from an earlier commit of this PR. A later commit
deletes them, so they are not part of the merged change.
| Before (light) | After (light) | After (dark) |
| --- | --- | --- |
|

| 
| 
|
## Risks
- Low risk. This is a UI-only change. No schema or API change.
- Agents that send a custom target without a label lose the visible
pill. The key was never readable display text. To show a pill, agents
set `label`.
## Merge Note
The first commit was authored as Paperclip. When you squash-merge, keep
this trailer in the squash body:
Co-Authored-By: Paperclip <Paperclip-Paperclip@users.noreply.github.com>
## Model Used
- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code agent
harness with tool use (shell, file edit). Standard context window.
## 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
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: devinfoley <139239+devinfoley@users.noreply.github.com>
1720 lines
70 KiB
TypeScript
1720 lines
70 KiB
TypeScript
// @vitest-environment jsdom
|
||
|
||
import { act as reactAct, type ComponentProps, type ReactNode } from "react";
|
||
import { flushSync } from "react-dom";
|
||
import { createRoot, type Root } from "react-dom/client";
|
||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||
import type { Agent } from "@paperclipai/shared";
|
||
import { ApiError } from "../api/client";
|
||
import { IssueThreadInteractionCard } from "./IssueThreadInteractionCard";
|
||
import { ThemeProvider } from "../context/ThemeContext";
|
||
import { TooltipProvider } from "./ui/tooltip";
|
||
import {
|
||
pendingAskUserQuestionsInteraction,
|
||
pendingAskUserQuestionsWithFreeTextOption,
|
||
commentExpiredAskUserQuestionsInteraction,
|
||
commentExpiredRequestConfirmationInteraction,
|
||
declinedToolActionInteraction,
|
||
disabledDeclineReasonRequestConfirmationInteraction,
|
||
executedToolActionInteraction,
|
||
expiredToolActionInteraction,
|
||
failedRequestConfirmationInteraction,
|
||
failedToolActionInteraction,
|
||
pendingRequestConfirmationInteraction,
|
||
pendingRequestCheckboxConfirmationInteraction,
|
||
pendingToolActionDestructiveInteraction,
|
||
pendingToolActionWriteInteraction,
|
||
issueThreadInteractionFixtureMeta,
|
||
pendingSecretProposalInteraction,
|
||
pendingConnectionAuthorizationInteraction,
|
||
resolvedConnectionAuthorizationInteraction,
|
||
executedSecretProposalInteraction,
|
||
failedSecretProposalInteraction,
|
||
rejectedSecretProposalInteraction,
|
||
expiredSecretProposalInteraction,
|
||
planApprovalResumeFailedRequestConfirmationInteraction,
|
||
pendingRequestItemVerdictsInteraction,
|
||
pendingSuggestedTasksInteraction,
|
||
runningToolActionInteraction,
|
||
completeRequestItemVerdictsInteraction,
|
||
supersededRequestItemVerdictsInteraction,
|
||
staleTargetRequestConfirmationInteraction,
|
||
rejectedSuggestedTasksInteraction,
|
||
agentAddressedRequestConfirmationInteraction,
|
||
agentResolvedRequestConfirmationInteraction,
|
||
withdrawnRequestConfirmationInteraction,
|
||
issueClosedRequestConfirmationInteraction,
|
||
notCreatorRequestConfirmationInteraction,
|
||
humanOnlyRequestConfirmationInteraction,
|
||
companyCappedRequestConfirmationInteraction,
|
||
legacyRestrictedRequestConfirmationInteraction,
|
||
pendingConnectionIntentInteraction,
|
||
retryConnectionIntentInteraction,
|
||
connectedConnectionIntentInteraction,
|
||
} from "../fixtures/issueThreadInteractionFixtures";
|
||
|
||
let root: Root | null = null;
|
||
let container: HTMLDivElement | null = null;
|
||
|
||
const connectionIntentsApiMocks = vi.hoisted(() => ({
|
||
setupOptions: vi.fn(),
|
||
complete: vi.fn(),
|
||
decline: vi.fn(),
|
||
}));
|
||
|
||
const routerMocks = vi.hoisted(() => ({
|
||
searchParams: new URLSearchParams(),
|
||
readSearchParams: vi.fn(),
|
||
setSearchParams: vi.fn(),
|
||
navigate: vi.fn(),
|
||
setBreadcrumbs: vi.fn(),
|
||
pushToast: vi.fn(),
|
||
}));
|
||
|
||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||
|
||
async function act(callback: () => void | Promise<void>) {
|
||
if (typeof reactAct === "function") {
|
||
await reactAct(callback);
|
||
return;
|
||
}
|
||
|
||
let result: void | Promise<void> = undefined;
|
||
flushSync(() => {
|
||
result = callback();
|
||
});
|
||
await result;
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
}
|
||
|
||
vi.mock("@/lib/router", () => ({
|
||
useParams: () => ({}),
|
||
useSearchParams: () => {
|
||
routerMocks.readSearchParams();
|
||
return [routerMocks.searchParams, routerMocks.setSearchParams];
|
||
},
|
||
useNavigate: () => routerMocks.navigate,
|
||
Link: ({ to, children, className }: { to: string; children: ReactNode; className?: string }) => (
|
||
<a href={to} className={className}>{children}</a>
|
||
),
|
||
}));
|
||
|
||
vi.mock("@/context/CompanyContext", async (importOriginal) => ({
|
||
...await importOriginal<typeof import("../context/CompanyContext")>(),
|
||
useCompany: () => ({ selectedCompanyId: issueThreadInteractionFixtureMeta.companyId, selectedCompany: null }),
|
||
}));
|
||
vi.mock("@/context/BreadcrumbContext", async (importOriginal) => ({
|
||
...await importOriginal<typeof import("../context/BreadcrumbContext")>(),
|
||
useBreadcrumbs: () => ({ setBreadcrumbs: routerMocks.setBreadcrumbs }),
|
||
}));
|
||
vi.mock("@/context/ToastContext", async (importOriginal) => ({
|
||
...await importOriginal<typeof import("../context/ToastContext")>(),
|
||
useToast: () => ({ pushToast: routerMocks.pushToast }),
|
||
}));
|
||
|
||
vi.mock("@/api/connection-intents", () => ({ connectionIntentsApi: connectionIntentsApiMocks }));
|
||
|
||
vi.mock("./task-chat/TaskChatRichInput", () => ({
|
||
TaskChatRichInput: ({ value, onChange }: { value: string; onChange: (value: string) => void }) => (
|
||
<textarea aria-label="Canonical answer" value={value} onChange={(event) => onChange(event.target.value)} />
|
||
),
|
||
}));
|
||
|
||
function renderCard(
|
||
props: Partial<ComponentProps<typeof IssueThreadInteractionCard>> = {},
|
||
) {
|
||
container = document.createElement("div");
|
||
document.body.appendChild(container);
|
||
root = createRoot(container);
|
||
const queryClient = new QueryClient({
|
||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||
});
|
||
|
||
act(() => {
|
||
root?.render(
|
||
<QueryClientProvider client={queryClient}>
|
||
<TooltipProvider>
|
||
<ThemeProvider>
|
||
<IssueThreadInteractionCard
|
||
interaction={pendingAskUserQuestionsInteraction}
|
||
{...props}
|
||
/>
|
||
</ThemeProvider>
|
||
</TooltipProvider>
|
||
</QueryClientProvider>,
|
||
);
|
||
});
|
||
|
||
return container;
|
||
}
|
||
|
||
afterEach(() => {
|
||
if (root) {
|
||
act(() => root?.unmount());
|
||
}
|
||
container?.remove();
|
||
root = null;
|
||
container = null;
|
||
localStorage.clear();
|
||
routerMocks.readSearchParams.mockClear();
|
||
});
|
||
|
||
describe("IssueThreadInteractionCard", () => {
|
||
const canonicalDraftInteraction = {
|
||
...pendingAskUserQuestionsInteraction,
|
||
payload: {
|
||
...pendingAskUserQuestionsInteraction.payload,
|
||
questions: [{ id: "draft", prompt: "Edit draft", required: true, selectionMode: "single" as const, options: [{ id: "draft_text", label: "Write an answer", freeText: true }], allowOther: false }],
|
||
questionSet: {
|
||
schema: "paperclip.question_set.v1" as const,
|
||
questions: [{ id: "draft", prompt: "Edit draft", required: true, answerMode: "text" as const, initialText: " Provider draft\n漢字\\n " }],
|
||
},
|
||
},
|
||
};
|
||
|
||
async function editCanonical(host: HTMLDivElement, value: string) {
|
||
const textarea = host.querySelector<HTMLTextAreaElement>("textarea")!;
|
||
await act(() => {
|
||
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!.call(textarea, value);
|
||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||
});
|
||
}
|
||
|
||
it("presents canonical drafts and submits only explicitly edited exact text", async () => {
|
||
const submit = vi.fn();
|
||
const host = renderCard({ interaction: canonicalDraftInteraction, onSubmitInteractionAnswers: submit });
|
||
expect(host.querySelector<HTMLTextAreaElement>("textarea")!.value).toBe(" Provider draft\n漢字\\n ");
|
||
expect(submit).not.toHaveBeenCalled();
|
||
const edited = "\n Operator edit 漢字\\n\n ";
|
||
await editCanonical(host, edited);
|
||
await act(() => Array.from(host.querySelectorAll("button")).find((button) => button.textContent === "Submit answers")!.click());
|
||
expect(submit).toHaveBeenCalledExactlyOnceWith(canonicalDraftInteraction, [{ questionId: "draft", optionIds: [], otherText: edited }]);
|
||
});
|
||
|
||
it("keeps an explicit cleared canonical draft after reopening and requires an answer", async () => {
|
||
const submit = vi.fn();
|
||
const host = renderCard({ interaction: canonicalDraftInteraction, onSubmitInteractionAnswers: submit });
|
||
await editCanonical(host, "");
|
||
await act(() => root!.unmount());
|
||
host.remove();
|
||
const reopened = renderCard({ interaction: canonicalDraftInteraction, onSubmitInteractionAnswers: submit });
|
||
expect(reopened.querySelector<HTMLTextAreaElement>("textarea")!.value).toBe("");
|
||
expect(Array.from(reopened.querySelectorAll("button")).find((button) => button.textContent === "Submit answers")!.disabled).toBe(true);
|
||
expect(submit).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("opens the shared connection setup for the addressed user", async () => {
|
||
connectionIntentsApiMocks.setupOptions.mockResolvedValue({ existingConnections: [] });
|
||
const host = renderCard({
|
||
interaction: pendingConnectionIntentInteraction,
|
||
currentUserId: issueThreadInteractionFixtureMeta.currentUserId,
|
||
});
|
||
|
||
expect(host.querySelector('[data-testid="connection-intent-actions"]')).toBeTruthy();
|
||
expect(host.textContent).toContain("Connect");
|
||
expect(host.textContent).toContain("Not now");
|
||
const loadButton = Array.from(host.querySelectorAll("button")).find((button) =>
|
||
button.textContent?.trim() === "Connect",
|
||
);
|
||
await act(async () => {
|
||
loadButton?.click();
|
||
await Promise.resolve();
|
||
});
|
||
await vi.waitFor(() =>
|
||
expect(connectionIntentsApiMocks.setupOptions).toHaveBeenCalledWith(
|
||
"interaction-connection-intent-default",
|
||
),
|
||
);
|
||
// setupOptions being called precedes React Query publishing its result.
|
||
// Wait for the real setup flow, not just the dialog's loading shell.
|
||
await vi.waitFor(async () => {
|
||
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
|
||
const setupDialog = document.body.querySelector('[role="dialog"]');
|
||
expect(setupDialog).toBeTruthy();
|
||
expect(setupDialog?.textContent).not.toContain("Loading connection options");
|
||
expect(routerMocks.readSearchParams).toHaveBeenCalled();
|
||
});
|
||
const dialog = document.body.querySelector('[role="dialog"]');
|
||
expect(dialog).toBeTruthy();
|
||
expect(dialog?.textContent).toContain("Connect Notion");
|
||
expect(dialog?.textContent).toContain("Complete connection setup without leaving this task.");
|
||
});
|
||
|
||
it("keeps connection resolution controls exclusive to the addressed user", () => {
|
||
const host = renderCard({
|
||
interaction: pendingConnectionIntentInteraction,
|
||
currentUserId: "another-user",
|
||
});
|
||
|
||
expect(host.querySelector('[data-testid="connection-intent-waiting"]')).toBeTruthy();
|
||
expect(Array.from(host.querySelectorAll("button")).map((button) => button.textContent?.trim())).not.toContain("Connect");
|
||
expect(host.textContent).not.toContain("Not now");
|
||
});
|
||
|
||
it("exposes pending question options as selectable radio and checkbox controls", () => {
|
||
const host = renderCard({
|
||
interaction: pendingAskUserQuestionsInteraction,
|
||
onSubmitInteractionAnswers: vi.fn(),
|
||
});
|
||
|
||
const singleGroup = host.querySelector('[role="radiogroup"]');
|
||
expect(singleGroup?.getAttribute("aria-labelledby")).toBe(
|
||
"interaction-questions-default-collapse-depth-prompt",
|
||
);
|
||
|
||
const radios = [...host.querySelectorAll('[role="radio"]')];
|
||
expect(radios).toHaveLength(2);
|
||
expect(radios[0]?.getAttribute("aria-checked")).toBe("false");
|
||
|
||
act(() => {
|
||
(radios[0] as HTMLButtonElement).click();
|
||
});
|
||
|
||
expect(radios[0]?.getAttribute("aria-checked")).toBe("true");
|
||
expect(radios[1]?.getAttribute("aria-checked")).toBe("false");
|
||
|
||
const multiGroup = host.querySelector('[role="group"]');
|
||
expect(multiGroup?.getAttribute("aria-labelledby")).toBe(
|
||
"interaction-questions-default-post-submit-summary-prompt",
|
||
);
|
||
expect(host.querySelectorAll('[role="checkbox"]')).toHaveLength(3);
|
||
|
||
const otherLink = Array.from(host.querySelectorAll("button")).find((button) =>
|
||
button.textContent === "Other",
|
||
);
|
||
expect(otherLink?.getAttribute("role")).toBeNull();
|
||
expect(otherLink?.className).toContain("underline");
|
||
});
|
||
|
||
it("submits written Other answers for pending questions", async () => {
|
||
const onSubmitInteractionAnswers = vi.fn(async () => undefined);
|
||
const host = renderCard({
|
||
interaction: pendingAskUserQuestionsInteraction,
|
||
onSubmitInteractionAnswers,
|
||
});
|
||
|
||
const otherButtons = Array.from(host.querySelectorAll("button")).filter((button) =>
|
||
button.textContent?.includes("Other"),
|
||
);
|
||
expect(otherButtons.length).toBeGreaterThan(0);
|
||
|
||
await act(async () => {
|
||
otherButtons[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
|
||
const textarea = host.querySelector("textarea") as HTMLTextAreaElement | null;
|
||
expect(textarea).toBeTruthy();
|
||
|
||
await act(async () => {
|
||
const valueSetter = Object.getOwnPropertyDescriptor(
|
||
HTMLTextAreaElement.prototype,
|
||
"value",
|
||
)?.set;
|
||
valueSetter?.call(textarea, "Keep only the root item open");
|
||
textarea!.dispatchEvent(new Event("input", { bubbles: true }));
|
||
});
|
||
|
||
const summaryCheckbox = Array.from(host.querySelectorAll('[role="checkbox"]')).find((button) =>
|
||
button.textContent?.includes("Inline answer pills"),
|
||
);
|
||
await act(async () => {
|
||
summaryCheckbox?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
|
||
const submitButton = Array.from(host.querySelectorAll("button")).find((button) =>
|
||
button.textContent?.includes("Send answers"),
|
||
);
|
||
await act(async () => {
|
||
submitButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
|
||
expect(onSubmitInteractionAnswers).toHaveBeenCalledWith(
|
||
expect.objectContaining({ kind: "ask_user_questions" }),
|
||
[
|
||
{
|
||
questionId: "collapse-depth",
|
||
optionIds: [],
|
||
otherText: "Keep only the root item open",
|
||
},
|
||
{
|
||
questionId: "post-submit-summary",
|
||
optionIds: ["answers-inline"],
|
||
},
|
||
],
|
||
);
|
||
});
|
||
|
||
it("reveals an inline field when a free-text option is selected and hides the standalone Other link", async () => {
|
||
const onSubmitInteractionAnswers = vi.fn(async () => undefined);
|
||
const host = renderCard({
|
||
interaction: pendingAskUserQuestionsWithFreeTextOption,
|
||
onSubmitInteractionAnswers,
|
||
});
|
||
|
||
// A first-class free-text option suppresses the built-in "Other" link.
|
||
const otherLink = Array.from(host.querySelectorAll("button")).find(
|
||
(button) => button.textContent === "Other",
|
||
);
|
||
expect(otherLink).toBeUndefined();
|
||
|
||
// No text field until the free-text option is selected.
|
||
expect(host.querySelector("textarea")).toBeNull();
|
||
|
||
const describeOption = Array.from(host.querySelectorAll('[role="radio"]')).find(
|
||
(button) => button.textContent?.includes("I'll describe it"),
|
||
) as HTMLButtonElement | undefined;
|
||
expect(describeOption).toBeTruthy();
|
||
|
||
await act(async () => {
|
||
describeOption?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
|
||
expect(describeOption?.getAttribute("aria-checked")).toBe("true");
|
||
const textarea = host.querySelector("textarea") as HTMLTextAreaElement | null;
|
||
expect(textarea).toBeTruthy();
|
||
|
||
await act(async () => {
|
||
const valueSetter = Object.getOwnPropertyDescriptor(
|
||
HTMLTextAreaElement.prototype,
|
||
"value",
|
||
)?.set;
|
||
valueSetter?.call(textarea, "Call it Threads");
|
||
textarea!.dispatchEvent(new Event("input", { bubbles: true }));
|
||
});
|
||
|
||
const submitButton = Array.from(host.querySelectorAll("button")).find((button) =>
|
||
button.textContent?.includes("Send answers"),
|
||
);
|
||
await act(async () => {
|
||
submitButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
|
||
expect(onSubmitInteractionAnswers).toHaveBeenCalledWith(
|
||
expect.objectContaining({ kind: "ask_user_questions" }),
|
||
[
|
||
{
|
||
questionId: "surface-name",
|
||
optionIds: [],
|
||
otherText: "Call it Threads",
|
||
},
|
||
],
|
||
);
|
||
});
|
||
|
||
it("renders nothing for a degenerate ask_user_questions card", () => {
|
||
// A truly unanswerable question: a prompt with no options and no free-text
|
||
// field, so there is nothing for the user to select or type. Hiding it
|
||
// strands nothing.
|
||
const degenerate = {
|
||
...pendingAskUserQuestionsInteraction,
|
||
id: "interaction-questions-degenerate",
|
||
payload: {
|
||
version: 1 as const,
|
||
title: "Placeholder",
|
||
questions: [
|
||
{
|
||
id: "q1",
|
||
prompt: "Anything?",
|
||
selectionMode: "single" as const,
|
||
options: [],
|
||
},
|
||
],
|
||
},
|
||
};
|
||
|
||
const host = renderCard({
|
||
interaction: degenerate,
|
||
onSubmitInteractionAnswers: vi.fn(),
|
||
});
|
||
|
||
// No card wrapper, no title, no controls — the component returns null.
|
||
expect(host.childElementCount).toBe(0);
|
||
expect(host.textContent).toBe("");
|
||
});
|
||
|
||
it("still renders a legitimate ask_user_questions card", () => {
|
||
const host = renderCard({
|
||
interaction: pendingAskUserQuestionsInteraction,
|
||
onSubmitInteractionAnswers: vi.fn(),
|
||
});
|
||
|
||
expect(host.childElementCount).toBeGreaterThan(0);
|
||
expect(host.querySelectorAll('[role="radio"]').length).toBeGreaterThan(0);
|
||
});
|
||
|
||
it("keeps a closed native select set closed while preserving direct-question defaults", () => {
|
||
const closed = {
|
||
...pendingAskUserQuestionsInteraction,
|
||
payload: {
|
||
...pendingAskUserQuestionsInteraction.payload,
|
||
questions: pendingAskUserQuestionsInteraction.payload.questions.map((question) => ({
|
||
...question,
|
||
allowOther: false,
|
||
})),
|
||
},
|
||
};
|
||
const host = renderCard({ interaction: closed, onSubmitInteractionAnswers: vi.fn() });
|
||
expect(Array.from(host.querySelectorAll("button")).some((button) => button.textContent === "Other"))
|
||
.toBe(false);
|
||
|
||
act(() => root?.unmount());
|
||
host.remove();
|
||
root = null;
|
||
const legacy = renderCard({
|
||
interaction: pendingAskUserQuestionsInteraction,
|
||
onSubmitInteractionAnswers: vi.fn(),
|
||
});
|
||
expect(Array.from(legacy.querySelectorAll("button")).some((button) => button.textContent === "Other"))
|
||
.toBe(true);
|
||
});
|
||
|
||
it("only shows question cancellation when a cancel handler is wired", () => {
|
||
const withoutHandler = renderCard({
|
||
interaction: pendingAskUserQuestionsInteraction,
|
||
onSubmitInteractionAnswers: vi.fn(),
|
||
});
|
||
expect(withoutHandler.textContent).not.toContain("Cancel question");
|
||
|
||
act(() => root?.unmount());
|
||
withoutHandler.remove();
|
||
root = null;
|
||
|
||
const withHandler = renderCard({
|
||
interaction: pendingAskUserQuestionsInteraction,
|
||
onCancelInteraction: vi.fn(),
|
||
onSubmitInteractionAnswers: vi.fn(),
|
||
});
|
||
expect(withHandler.textContent).toContain("Cancel question");
|
||
});
|
||
|
||
it("renders expired question interactions as resolved and non-actionable", () => {
|
||
const host = renderCard({
|
||
interaction: commentExpiredAskUserQuestionsInteraction,
|
||
onSubmitInteractionAnswers: vi.fn(),
|
||
onCancelInteraction: vi.fn(),
|
||
});
|
||
|
||
expect(host.textContent).toContain("Questions expired by comment");
|
||
expect(host.textContent).toContain("A later board/user comment superseded this question request.");
|
||
expect(host.textContent).not.toContain("Send answers");
|
||
expect(host.textContent).not.toContain("Cancel question");
|
||
|
||
const jumpLink = Array.from(host.querySelectorAll("a")).find((link) =>
|
||
link.textContent?.includes("Jump to comment"),
|
||
);
|
||
expect(jumpLink?.getAttribute("href")).toBe(
|
||
"#comment-22222222-2222-4222-8222-222222222222",
|
||
);
|
||
});
|
||
|
||
it("uses singular copy for expired single-question interactions", () => {
|
||
const [question] = commentExpiredAskUserQuestionsInteraction.payload.questions;
|
||
const host = renderCard({
|
||
interaction: {
|
||
...commentExpiredAskUserQuestionsInteraction,
|
||
payload: {
|
||
...commentExpiredAskUserQuestionsInteraction.payload,
|
||
questions: [question],
|
||
},
|
||
},
|
||
});
|
||
|
||
expect(host.textContent).toContain("Question expired by comment");
|
||
expect(host.textContent).not.toContain("Questions expired by comment");
|
||
});
|
||
|
||
it("renders withdrawn confirmations with the withdraw reason", () => {
|
||
const host = renderCard({
|
||
interaction: {
|
||
...pendingRequestConfirmationInteraction,
|
||
status: "cancelled",
|
||
result: { version: 1, outcome: "withdrawn", reason: "Superseded by the hotfix plan." },
|
||
},
|
||
onAcceptInteraction: vi.fn(),
|
||
onRejectInteraction: vi.fn(),
|
||
});
|
||
|
||
expect(host.textContent).toContain("Withdrawn");
|
||
expect(host.textContent).toContain("Superseded by the hotfix plan.");
|
||
expect(host.textContent).not.toContain("Decline");
|
||
});
|
||
|
||
it("renders confirmations expired by issue closure with dedicated copy", () => {
|
||
const host = renderCard({
|
||
interaction: {
|
||
...pendingRequestConfirmationInteraction,
|
||
status: "expired",
|
||
result: { version: 1, outcome: "issue_closed", reason: null },
|
||
},
|
||
});
|
||
|
||
expect(host.textContent).toContain("Expired · issue closed");
|
||
expect(host.textContent).toContain("This confirmation expired automatically when the issue reached a terminal state.");
|
||
expect(host.textContent).not.toContain("Expired by target change");
|
||
});
|
||
|
||
it("renders withdrawn question interactions with the withdraw reason", () => {
|
||
const host = renderCard({
|
||
interaction: {
|
||
...pendingAskUserQuestionsInteraction,
|
||
status: "cancelled",
|
||
result: {
|
||
version: 1,
|
||
outcome: "withdrawn",
|
||
reason: "Scope was decided on the parent issue.",
|
||
answers: [],
|
||
summaryMarkdown: null,
|
||
},
|
||
},
|
||
});
|
||
|
||
expect(host.textContent).toContain("Questions withdrawn");
|
||
expect(host.textContent).toContain("Scope was decided on the parent issue.");
|
||
expect(host.textContent).not.toContain("Question cancelled");
|
||
});
|
||
|
||
it("renders question interactions expired by issue closure with dedicated copy", () => {
|
||
const host = renderCard({
|
||
interaction: {
|
||
...pendingAskUserQuestionsInteraction,
|
||
status: "expired",
|
||
result: {
|
||
version: 1,
|
||
outcome: "issue_closed",
|
||
reason: null,
|
||
answers: [],
|
||
summaryMarkdown: null,
|
||
},
|
||
},
|
||
});
|
||
|
||
expect(host.textContent).toContain("Questions expired when the issue closed");
|
||
expect(host.textContent).toContain("This question request expired automatically when the issue reached a terminal state.");
|
||
expect(host.textContent).not.toContain("expired by comment");
|
||
});
|
||
|
||
it("makes child tasks explicit in suggested task trees", () => {
|
||
const host = renderCard({
|
||
interaction: pendingSuggestedTasksInteraction,
|
||
});
|
||
|
||
expect(host.textContent).toContain("Child task");
|
||
});
|
||
|
||
it("shows an explicit placeholder when a rejected interaction has no reason", () => {
|
||
const host = renderCard({
|
||
interaction: {
|
||
...rejectedSuggestedTasksInteraction,
|
||
result: { version: 1 },
|
||
},
|
||
});
|
||
|
||
expect(host.textContent).toContain("No reason provided.");
|
||
});
|
||
|
||
it("requires a revision note when the request confirmation payload asks for one", async () => {
|
||
const onRejectInteraction = vi.fn(async () => undefined);
|
||
const host = renderCard({
|
||
interaction: pendingRequestConfirmationInteraction,
|
||
onRejectInteraction,
|
||
});
|
||
|
||
// rejectRequiresReason drops the bare Reject: the only send-back path is Revise…
|
||
expect(Array.from(host.querySelectorAll("button")).some((button) =>
|
||
button.textContent?.trim() === "Reject",
|
||
)).toBe(false);
|
||
const reviseButton = Array.from(host.querySelectorAll("button")).find((button) =>
|
||
button.textContent?.includes("Revise"),
|
||
);
|
||
expect(reviseButton).toBeTruthy();
|
||
|
||
await act(async () => {
|
||
reviseButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
|
||
const sendButton = Array.from(host.querySelectorAll("button")).find((button) =>
|
||
button.textContent?.includes("Send revision"),
|
||
);
|
||
expect(sendButton?.hasAttribute("disabled")).toBe(false);
|
||
|
||
await act(async () => {
|
||
sendButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
|
||
expect(host.textContent).toContain("Add a note describing the changes you want.");
|
||
expect(onRejectInteraction).not.toHaveBeenCalled();
|
||
|
||
const textarea = host.querySelector("textarea") as HTMLTextAreaElement | null;
|
||
expect(textarea).toBeTruthy();
|
||
expect(textarea?.getAttribute("aria-invalid")).toBe("true");
|
||
|
||
await act(async () => {
|
||
const valueSetter = Object.getOwnPropertyDescriptor(
|
||
HTMLTextAreaElement.prototype,
|
||
"value",
|
||
)?.set;
|
||
valueSetter?.call(textarea, "Needs a smaller phase split");
|
||
textarea!.dispatchEvent(new Event("input", { bubbles: true }));
|
||
});
|
||
const enabledSendButton = Array.from(host.querySelectorAll("button")).find((button) =>
|
||
button.textContent?.includes("Send revision"),
|
||
);
|
||
expect(enabledSendButton?.hasAttribute("disabled")).toBe(false);
|
||
await act(async () => {
|
||
enabledSendButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
|
||
expect(onRejectInteraction).toHaveBeenCalledWith(
|
||
expect.objectContaining({ kind: "request_confirmation" }),
|
||
"Needs a smaller phase split",
|
||
);
|
||
});
|
||
|
||
it.each([
|
||
pendingRequestConfirmationInteraction,
|
||
pendingRequestCheckboxConfirmationInteraction,
|
||
pendingSecretProposalInteraction,
|
||
])("keeps $kind approval disabled while preparing without disabling rejection", async (fixture) => {
|
||
const onAcceptInteraction = vi.fn();
|
||
const host = renderCard({
|
||
interaction: { ...fixture, acceptanceBlocker: "workspace_sync_pending" },
|
||
onAcceptInteraction,
|
||
onRejectInteraction: vi.fn(),
|
||
});
|
||
expect(host.querySelector('[role="status"]')?.textContent).toContain("Preparing approval…");
|
||
const approve = host.querySelector<HTMLButtonElement>('[data-testid="confirmation-actions"] button')!;
|
||
expect(approve.disabled).toBe(true);
|
||
await act(() => approve.click());
|
||
expect(onAcceptInteraction).not.toHaveBeenCalled();
|
||
const reject = [...host.querySelectorAll<HTMLButtonElement>("button")].find((button) => ["Reject", "Revise…", "Add reason…"].includes(button.textContent ?? ""));
|
||
expect(reject?.disabled).toBe(false);
|
||
});
|
||
|
||
it("invokes the confirm callback with pending request confirmations", async () => {
|
||
const onAcceptInteraction = vi.fn(async () => undefined);
|
||
const host = renderCard({
|
||
interaction: pendingRequestConfirmationInteraction,
|
||
onAcceptInteraction,
|
||
});
|
||
|
||
const confirmButton = Array.from(host.querySelectorAll("button")).find((button) =>
|
||
button.textContent?.includes("Approve plan"),
|
||
);
|
||
expect(confirmButton).toBeTruthy();
|
||
|
||
await act(async () => {
|
||
confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
|
||
expect(onAcceptInteraction).toHaveBeenCalledWith(
|
||
expect.objectContaining({ kind: "request_confirmation" }),
|
||
);
|
||
});
|
||
|
||
// PAP-17287: a denial is persistent, so the inline error keeps the server's
|
||
// reason and names who can respond instead of offering a doomed retry.
|
||
it("keeps the server denial reason in an aria-live region when a confirmation is refused", async () => {
|
||
const onAcceptInteraction = vi.fn(async () => {
|
||
throw new ApiError("This issue-thread interaction is human-only", 403, {
|
||
error: "This issue-thread interaction is human-only",
|
||
code: "interaction_human_only",
|
||
});
|
||
});
|
||
const host = renderCard({
|
||
interaction: humanOnlyRequestConfirmationInteraction,
|
||
onAcceptInteraction,
|
||
});
|
||
|
||
const confirmButton = Array.from(host.querySelectorAll("button")).find((button) =>
|
||
button.textContent?.includes("Approve"),
|
||
);
|
||
await act(async () => {
|
||
confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
|
||
const error = host.querySelector('[data-testid="interaction-action-error"]');
|
||
expect(error?.getAttribute("aria-live")).toBe("assertive");
|
||
expect(error?.textContent).toContain("This issue-thread interaction is human-only.");
|
||
expect(error?.textContent).toContain("Only the board can respond.");
|
||
expect(error?.textContent).not.toMatch(/try again/i);
|
||
// PAP-17289: one live region, not two. `role="alert"` is itself an
|
||
// assertive live region, so nesting it inside this wrapper can announce the
|
||
// same denial twice.
|
||
expect(error?.querySelector('[role="alert"]')).toBeNull();
|
||
expect(host.querySelectorAll('[aria-live], [role="alert"]').length).toBe(1);
|
||
});
|
||
|
||
it("still offers a retry when a resolution fails for a transient reason", async () => {
|
||
const onAcceptInteraction = vi.fn(async () => {
|
||
throw new ApiError("Request failed: 503", 503, null);
|
||
});
|
||
const host = renderCard({
|
||
interaction: pendingRequestConfirmationInteraction,
|
||
onAcceptInteraction,
|
||
});
|
||
|
||
await act(async () => {
|
||
Array.from(host.querySelectorAll("button"))
|
||
.find((button) => button.textContent?.includes("Approve plan"))
|
||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
|
||
expect(
|
||
host.querySelector('[data-testid="interaction-action-error"]')?.textContent,
|
||
).toBe("Request failed: 503. Try again.");
|
||
});
|
||
|
||
it("surfaces a denied suggested-task acceptance instead of failing silently", async () => {
|
||
const onAcceptInteraction = vi.fn(async () => {
|
||
throw new ApiError("Only the addressed agent or an authorized human may resolve this issue-thread interaction", 403, {
|
||
error: "Only the addressed agent or an authorized human may resolve this issue-thread interaction",
|
||
code: "interaction_addressee_mismatch",
|
||
});
|
||
});
|
||
const host = renderCard({
|
||
interaction: pendingSuggestedTasksInteraction,
|
||
onAcceptInteraction,
|
||
});
|
||
|
||
await act(async () => {
|
||
Array.from(host.querySelectorAll("button"))
|
||
.find((button) => button.textContent?.includes("Accept"))
|
||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
|
||
const error = host.querySelector('[data-testid="interaction-action-error"]');
|
||
expect(error?.getAttribute("aria-live")).toBe("assertive");
|
||
expect(error?.textContent).toContain("may resolve this issue-thread interaction.");
|
||
});
|
||
|
||
it("surfaces a denied answer submission on a questions card", async () => {
|
||
const onSubmitInteractionAnswers = vi.fn(async () => {
|
||
throw new ApiError("This issue-thread interaction is human-only", 403, {
|
||
error: "This issue-thread interaction is human-only",
|
||
code: "interaction_human_only",
|
||
});
|
||
});
|
||
const host = renderCard({
|
||
interaction: pendingAskUserQuestionsInteraction,
|
||
onSubmitInteractionAnswers,
|
||
});
|
||
|
||
// Answer every question so Submit is enabled, then submit.
|
||
for (const group of ['[role="radio"]', '[role="checkbox"]']) {
|
||
const option = host.querySelector(group);
|
||
await act(async () => {
|
||
(option as HTMLElement | null)?.click();
|
||
});
|
||
}
|
||
const submit = Array.from(host.querySelectorAll("button")).find((button) =>
|
||
button.textContent?.includes("Send answers"),
|
||
);
|
||
expect(submit?.hasAttribute("disabled")).toBe(false);
|
||
await act(async () => {
|
||
submit?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
|
||
expect(
|
||
host.querySelector('[data-testid="interaction-action-error"]')?.textContent,
|
||
).toContain("This issue-thread interaction is human-only.");
|
||
});
|
||
|
||
it("standardizes the bare-reject button to Reject even when the payload carries a legacy rejectLabel", () => {
|
||
const host = renderCard({
|
||
interaction: {
|
||
...pendingRequestConfirmationInteraction,
|
||
payload: {
|
||
...pendingRequestConfirmationInteraction.payload,
|
||
// Onboarding/plan-approval interactions are still seeded with the
|
||
// legacy "Request changes" reject label; it must not leak into the CTA.
|
||
rejectLabel: "Request changes",
|
||
rejectRequiresReason: false,
|
||
},
|
||
},
|
||
onAcceptInteraction: vi.fn(async () => undefined),
|
||
onRejectInteraction: vi.fn(async () => undefined),
|
||
});
|
||
|
||
const labels = Array.from(host.querySelectorAll("button")).map((button) =>
|
||
button.textContent?.trim(),
|
||
);
|
||
|
||
// Canonical plan-approval grammar, right→left: Approve · Revise… · Reject.
|
||
// "Revise…" already carries the send-back-with-notes path, so a distinct
|
||
// "Request changes" word is redundant and must not render.
|
||
expect(labels).toContain("Reject");
|
||
expect(labels).toContain("Revise…");
|
||
expect(labels.some((label) => label?.includes("Approve"))).toBe(true);
|
||
expect(host.textContent).not.toContain("Request changes");
|
||
});
|
||
|
||
it("does not expose continuation wake policy labels in the card header", () => {
|
||
const host = renderCard({
|
||
interaction: {
|
||
...pendingRequestConfirmationInteraction,
|
||
continuationPolicy: "wake_assignee_on_accept",
|
||
},
|
||
});
|
||
|
||
expect(host.textContent).not.toContain("Wakes on confirm");
|
||
expect(host.textContent).not.toContain("Wakes assignee");
|
||
});
|
||
|
||
it("renders request confirmation target links and stale-target expiry", () => {
|
||
const host = renderCard({
|
||
interaction: staleTargetRequestConfirmationInteraction,
|
||
});
|
||
|
||
const targetLinks = host.querySelectorAll("a");
|
||
expect(host.textContent).toContain("Expired by target change");
|
||
expect(host.textContent).toContain("Plan v3");
|
||
expect(host.textContent).toContain("Plan v4");
|
||
expect(targetLinks[0]?.getAttribute("href")).toContain("#document-plan");
|
||
expect(targetLinks[1]?.getAttribute("href")).toContain("#document-plan");
|
||
expect(host.querySelector(".lucide-chevron-right")).not.toBeNull();
|
||
expect(host.textContent).not.toContain("Approve plan");
|
||
});
|
||
|
||
describe("custom request confirmation targets", () => {
|
||
const prKey = (pull: number) => `github:paperclipai/paperclip-desktop:pull:${pull}`;
|
||
const unlabeled = (pull: number) => ({ type: "custom" as const, key: prKey(pull) });
|
||
const labeled = (pull: number) => ({
|
||
...unlabeled(pull),
|
||
label: `PR #${pull}`,
|
||
href: `https://github.com/paperclipai/paperclip-desktop/pull/${pull}`,
|
||
});
|
||
const staleTargetChange = (
|
||
staleTarget: ReturnType<typeof unlabeled>,
|
||
target: ReturnType<typeof unlabeled>,
|
||
) => ({
|
||
...staleTargetRequestConfirmationInteraction,
|
||
payload: { ...staleTargetRequestConfirmationInteraction.payload, target },
|
||
result: { version: 1 as const, outcome: "stale_target" as const, staleTarget },
|
||
});
|
||
|
||
it("hides an unlabeled target key on a pending card", () => {
|
||
const host = renderCard({
|
||
interaction: {
|
||
...pendingRequestConfirmationInteraction,
|
||
payload: { ...pendingRequestConfirmationInteraction.payload, target: unlabeled(16) },
|
||
},
|
||
});
|
||
expect(host.textContent).not.toContain("github:paperclipai");
|
||
});
|
||
|
||
it("links a labeled target on a pending card", () => {
|
||
const host = renderCard({
|
||
interaction: {
|
||
...pendingRequestConfirmationInteraction,
|
||
payload: { ...pendingRequestConfirmationInteraction.payload, target: labeled(16) },
|
||
},
|
||
});
|
||
const link = host.querySelector(
|
||
'a[href="https://github.com/paperclipai/paperclip-desktop/pull/16"]',
|
||
);
|
||
expect(link?.textContent).toContain("PR #16");
|
||
expect(host.textContent).not.toContain("github:paperclipai");
|
||
});
|
||
|
||
it("shows no chips or chevron for an unlabeled stale-target change", () => {
|
||
const host = renderCard({ interaction: staleTargetChange(unlabeled(15), unlabeled(16)) });
|
||
expect(host.textContent).toContain("Expired by target change");
|
||
expect(host.textContent).not.toContain("github:paperclipai");
|
||
expect(host.querySelector(".lucide-chevron-right")).toBeNull();
|
||
});
|
||
|
||
it("links both labeled targets across a stale-target chevron", () => {
|
||
const host = renderCard({ interaction: staleTargetChange(labeled(15), labeled(16)) });
|
||
expect(host.textContent).toContain("PR #15");
|
||
expect(host.textContent).toContain("PR #16");
|
||
expect(host.textContent).not.toContain("github:paperclipai");
|
||
expect(host.querySelector(".lucide-chevron-right")).not.toBeNull();
|
||
});
|
||
|
||
it("drops the chevron when only one side of the stale-target change is labeled", () => {
|
||
const host = renderCard({ interaction: staleTargetChange(unlabeled(15), labeled(16)) });
|
||
expect(host.textContent).toContain("PR #16");
|
||
expect(host.textContent).not.toContain("github:paperclipai");
|
||
expect(host.querySelector(".lucide-chevron-right")).toBeNull();
|
||
});
|
||
});
|
||
|
||
it("renders a jump link for confirmations expired by comment", () => {
|
||
const host = renderCard({
|
||
interaction: commentExpiredRequestConfirmationInteraction,
|
||
});
|
||
|
||
const jumpLink = Array.from(host.querySelectorAll("a")).find((link) =>
|
||
link.textContent?.includes("Jump to comment"),
|
||
);
|
||
|
||
expect(jumpLink?.getAttribute("href")).toBe(
|
||
"#comment-22222222-2222-4222-8222-222222222222",
|
||
);
|
||
});
|
||
|
||
it("declines immediately when decline reasons are disabled", async () => {
|
||
const onRejectInteraction = vi.fn(async () => undefined);
|
||
const host = renderCard({
|
||
interaction: disabledDeclineReasonRequestConfirmationInteraction,
|
||
onRejectInteraction,
|
||
});
|
||
|
||
// The bare-reject button always renders the canonical "Reject", not the
|
||
// payload's "Keep it" — ConfirmationActionRow no longer honors the override.
|
||
const declineButton = Array.from(host.querySelectorAll("button")).find((button) =>
|
||
button.textContent?.trim() === "Reject",
|
||
);
|
||
expect(declineButton).toBeTruthy();
|
||
|
||
await act(async () => {
|
||
declineButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
|
||
expect(host.querySelector("textarea")).toBeNull();
|
||
expect(onRejectInteraction).toHaveBeenCalledWith(
|
||
expect.objectContaining({ kind: "request_confirmation" }),
|
||
undefined,
|
||
);
|
||
});
|
||
|
||
it("renders explicit copy for failed request confirmations", () => {
|
||
const host = renderCard({
|
||
interaction: failedRequestConfirmationInteraction,
|
||
});
|
||
|
||
expect(host.textContent).toContain(
|
||
"This request could not be resolved. Try again or create a new request.",
|
||
);
|
||
});
|
||
|
||
it("renders a plan confirmation as a distinct state-coloured plan card", () => {
|
||
const pending = renderCard({ interaction: pendingRequestConfirmationInteraction });
|
||
const pendingShell = pending.firstElementChild as HTMLElement;
|
||
expect(pendingShell.className).toContain("border-violet-500/80");
|
||
expect(pendingShell.className).not.toContain("border-l-");
|
||
expect(pending.textContent).toContain("Plan");
|
||
expect(pending.textContent).toContain("In review");
|
||
// Approve is a neutral CTA (foreground/background), not the blue primary.
|
||
const approve = Array.from(pending.querySelectorAll("button")).find((button) =>
|
||
button.textContent?.includes("Approve plan"),
|
||
);
|
||
expect(approve?.className).toContain("bg-foreground");
|
||
expect(approve?.className).not.toContain("bg-primary");
|
||
|
||
act(() => root?.unmount());
|
||
pending.remove();
|
||
root = null;
|
||
|
||
const accepted = renderCard({
|
||
interaction: { ...pendingRequestConfirmationInteraction, status: "accepted" },
|
||
});
|
||
expect((accepted.firstElementChild as HTMLElement).className).toContain("border-green-500/80");
|
||
expect(accepted.textContent).toContain("Approved");
|
||
|
||
act(() => root?.unmount());
|
||
accepted.remove();
|
||
root = null;
|
||
|
||
const resumeFailed = renderCard({
|
||
interaction: planApprovalResumeFailedRequestConfirmationInteraction,
|
||
});
|
||
expect((resumeFailed.firstElementChild as HTMLElement).className).toContain("border-amber-500/70");
|
||
expect(resumeFailed.textContent).toContain("Approved — agent resume failed");
|
||
expect(resumeFailed.textContent).toContain("Agent resume failed");
|
||
expect(resumeFailed.textContent).toContain("Paperclip needs attention before the agent can resume this approved work.");
|
||
expect(resumeFailed.textContent).toContain("adapter_failed");
|
||
|
||
act(() => root?.unmount());
|
||
resumeFailed.remove();
|
||
root = null;
|
||
|
||
const rejected = renderCard({
|
||
interaction: {
|
||
...pendingRequestConfirmationInteraction,
|
||
status: "rejected",
|
||
result: { version: 1, outcome: "rejected", reason: "Tighten the spacing" },
|
||
},
|
||
});
|
||
expect((rejected.firstElementChild as HTMLElement).className).toContain("border-red-500/80");
|
||
expect(rejected.textContent).toContain("Changes requested");
|
||
});
|
||
|
||
it("attaches screenshots to a plan request-changes reason as markdown images", async () => {
|
||
const onRejectInteraction = vi.fn(async () => undefined);
|
||
const onUploadImage = vi.fn(async () => "https://cdn.example/shot.png");
|
||
const host = renderCard({
|
||
interaction: {
|
||
...pendingRequestConfirmationInteraction,
|
||
payload: {
|
||
...pendingRequestConfirmationInteraction.payload,
|
||
rejectRequiresReason: false,
|
||
},
|
||
},
|
||
onRejectInteraction,
|
||
onUploadImage,
|
||
});
|
||
|
||
const reviseButton = Array.from(host.querySelectorAll("button")).find((button) =>
|
||
button.textContent?.includes("Revise"),
|
||
);
|
||
await act(async () => {
|
||
reviseButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
|
||
const attachButton = Array.from(host.querySelectorAll("button")).find((button) =>
|
||
button.textContent?.includes("Attach screenshots"),
|
||
);
|
||
expect(attachButton).toBeTruthy();
|
||
|
||
const fileInput = host.querySelector('input[type="file"]') as HTMLInputElement | null;
|
||
expect(fileInput).toBeTruthy();
|
||
const file = new File(["x"], "bug.png", { type: "image/png" });
|
||
Object.defineProperty(fileInput!, "files", { value: [file], configurable: true });
|
||
Object.defineProperty(fileInput!, "value", {
|
||
value: "C:/fake/bug.png",
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
|
||
await act(async () => {
|
||
fileInput!.dispatchEvent(new Event("change", { bubbles: true }));
|
||
});
|
||
expect(onUploadImage).toHaveBeenCalledTimes(1);
|
||
|
||
const sendButton = Array.from(host.querySelectorAll("button")).find((button) =>
|
||
button.textContent?.includes("Send revision"),
|
||
);
|
||
await act(async () => {
|
||
sendButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
|
||
expect(onRejectInteraction).toHaveBeenCalledWith(
|
||
expect.objectContaining({ kind: "request_confirmation" }),
|
||
"",
|
||
);
|
||
});
|
||
|
||
it("submits an approve verdict once a draft is marked and applied", async () => {
|
||
const onSubmitInteractionVerdicts = vi.fn(async () => undefined);
|
||
const host = renderCard({
|
||
interaction: pendingRequestItemVerdictsInteraction,
|
||
onSubmitInteractionVerdicts,
|
||
});
|
||
|
||
const firstItemId = pendingRequestItemVerdictsInteraction.payload.items[0]!.id;
|
||
const approveButton = Array.from(
|
||
host.querySelectorAll<HTMLButtonElement>(`[data-item-id="${firstItemId}"] button[data-verdict="approve"]`),
|
||
)[0];
|
||
expect(approveButton).toBeTruthy();
|
||
// 44px minimum target (a11y).
|
||
expect(approveButton?.className).toContain("min-h-11");
|
||
|
||
await act(async () => {
|
||
approveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
|
||
const applyButton = Array.from(host.querySelectorAll("button")).find((button) =>
|
||
button.textContent?.includes("Apply 1 decision"),
|
||
);
|
||
expect(applyButton).toBeTruthy();
|
||
|
||
await act(async () => {
|
||
applyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
|
||
expect(onSubmitInteractionVerdicts).toHaveBeenCalledWith(
|
||
expect.objectContaining({ kind: "request_item_verdicts" }),
|
||
[{ id: firstItemId, verdict: "approve", reason: undefined }],
|
||
);
|
||
});
|
||
|
||
it("blocks apply for a rejected item until a reason is entered", async () => {
|
||
const onSubmitInteractionVerdicts = vi.fn(async () => undefined);
|
||
const host = renderCard({
|
||
interaction: pendingRequestItemVerdictsInteraction,
|
||
onSubmitInteractionVerdicts,
|
||
});
|
||
|
||
const firstItemId = pendingRequestItemVerdictsInteraction.payload.items[0]!.id;
|
||
const rejectButton = Array.from(
|
||
host.querySelectorAll<HTMLButtonElement>(`[data-item-id="${firstItemId}"] button[data-verdict="reject"]`),
|
||
)[0];
|
||
await act(async () => {
|
||
rejectButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
|
||
// Reject reveals a required reason field.
|
||
const reasonField = host.querySelector<HTMLTextAreaElement>(
|
||
`textarea[id="${pendingRequestItemVerdictsInteraction.id}-${firstItemId}-reason"]`,
|
||
);
|
||
expect(reasonField).toBeTruthy();
|
||
|
||
const applyButton = Array.from(host.querySelectorAll("button")).find((button) =>
|
||
button.textContent?.includes("Apply 1 decision"),
|
||
);
|
||
// Attempting to apply without a reason does not submit.
|
||
await act(async () => {
|
||
applyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
expect(onSubmitInteractionVerdicts).not.toHaveBeenCalled();
|
||
expect(host.textContent).toContain("A reason is required to reject this item.");
|
||
});
|
||
|
||
it("renders resolved verdicts as terminal chips with reason echo", () => {
|
||
const host = renderCard({ interaction: completeRequestItemVerdictsInteraction });
|
||
expect(host.textContent).toContain("Approved");
|
||
expect(host.textContent).toContain("Rejected");
|
||
expect(host.textContent).toContain("Tone is off-brand");
|
||
// S5 summary chip.
|
||
expect(host.textContent).toContain("3 approved");
|
||
// No actionable verdict buttons once terminal.
|
||
expect(host.querySelector("button[data-verdict]")).toBeNull();
|
||
});
|
||
|
||
it("shows an already-applied, cannot-revert notice when superseded", () => {
|
||
const host = renderCard({ interaction: supersededRequestItemVerdictsInteraction });
|
||
expect(host.textContent).toContain("expired after a later comment");
|
||
expect(host.textContent).toContain("cannot be");
|
||
expect(host.textContent?.toLowerCase()).toContain("revert");
|
||
});
|
||
});
|
||
|
||
describe("IssueThreadInteractionCard tool-action card", () => {
|
||
it("shows only the request description, app icon, and decision controls", () => {
|
||
const host = renderCard({ interaction: pendingToolActionWriteInteraction, onAcceptInteraction: vi.fn(), onRejectInteraction: vi.fn() });
|
||
expect(host.textContent).toContain("Add 1 row");
|
||
expect(host.querySelector('[role="img"]')?.getAttribute("aria-label")).toBe("Google Sheets");
|
||
expect(host.textContent).toContain("Approve & run");
|
||
expect(host.textContent).toContain("Decline");
|
||
expect(host.textContent).not.toContain("Append row to spreadsheet");
|
||
expect(host.textContent).not.toContain("Technical details");
|
||
expect(host.textContent).not.toContain("Expires");
|
||
expect(host.textContent).not.toContain("args hash");
|
||
});
|
||
|
||
it("keeps reviewed argument values visible across preview paragraphs", () => {
|
||
const interaction = structuredClone(pendingToolActionWriteInteraction);
|
||
interaction.payload.toolAction!.previewMarkdown = "Add a row.\n\n**Title:** Launch checklist";
|
||
const host = renderCard({ interaction });
|
||
expect(host.textContent).toContain("Launch checklist");
|
||
});
|
||
|
||
it("keeps the destructive request warning and approval styling", () => {
|
||
const host = renderCard({ interaction: pendingToolActionDestructiveInteraction, onAcceptInteraction: vi.fn(), onRejectInteraction: vi.fn() });
|
||
expect(host.textContent).toContain("cannot be undone");
|
||
const approve = Array.from(host.querySelectorAll("button")).find(button => button.textContent === "Approve & run");
|
||
expect(approve?.getAttribute("data-variant")).toBe("destructive");
|
||
});
|
||
|
||
it("declines in one click without an optional reason form", async () => {
|
||
const reject = vi.fn();
|
||
const host = renderCard({ interaction: pendingToolActionWriteInteraction, onRejectInteraction: reject });
|
||
const decline = Array.from(host.querySelectorAll("button")).find(button => button.textContent === "Decline");
|
||
await act(async () => { decline?.click(); });
|
||
expect(reject).toHaveBeenCalledExactlyOnceWith(pendingToolActionWriteInteraction);
|
||
expect(host.querySelector("textarea")).toBeNull();
|
||
});
|
||
|
||
it("renders the approved-running state with a spinner and no action buttons", () => {
|
||
const host = renderCard({ interaction: runningToolActionInteraction });
|
||
|
||
expect(host.textContent).toContain("Running…");
|
||
expect(host.textContent).toContain("Approved · Running");
|
||
expect(host.textContent).not.toContain("Approve & run");
|
||
expect(host.querySelector(".animate-spin")).toBeTruthy();
|
||
});
|
||
|
||
it("keeps the executed result collapsed and never reads Accepted", () => {
|
||
const host = renderCard({ interaction: executedToolActionInteraction });
|
||
|
||
expect(host.textContent).toContain("Succeeded");
|
||
expect(host.textContent).not.toContain("Row 42 added");
|
||
expect(host.querySelector('button[aria-label="Show result details"]')?.getAttribute("aria-expanded")).toBe("false");
|
||
expect(host.textContent).not.toContain("Accepted");
|
||
const link = Array.from(host.querySelectorAll("a")).find((a) =>
|
||
a.textContent?.includes("View result"),
|
||
);
|
||
expect(link?.getAttribute("href")).toContain("docs.google.com");
|
||
});
|
||
|
||
it("expands stored results as formatted JSON on demand", async () => {
|
||
const interaction = structuredClone(executedToolActionInteraction);
|
||
interaction.result!.toolAction!.resultSummary = '{"pages":["Roadmap","Meeting notes"]}';
|
||
const host = renderCard({ interaction });
|
||
expect(host.textContent).not.toContain("Roadmap");
|
||
const toggle = host.querySelector('button[aria-label="Show result details"]') as HTMLButtonElement;
|
||
await act(async () => toggle.click());
|
||
expect(host.querySelector("pre")?.textContent).toBe(JSON.stringify({ pages: ["Roadmap", "Meeting notes"] }, null, 2));
|
||
expect(toggle.getAttribute("aria-expanded")).toBe("true");
|
||
await act(async () => toggle.click());
|
||
expect(host.textContent).not.toContain("Roadmap");
|
||
});
|
||
|
||
it("distinguishes failed (ran + connector error) from declined (did not run)", () => {
|
||
const failed = renderCard({ interaction: failedToolActionInteraction });
|
||
expect(failed.textContent).toContain("Execution failed");
|
||
expect(failed.textContent).toContain(failedToolActionInteraction.result!.toolAction!.errorMessage);
|
||
|
||
act(() => root?.unmount());
|
||
failed.remove();
|
||
root = null;
|
||
|
||
const declined = renderCard({ interaction: declinedToolActionInteraction });
|
||
expect(declined.textContent).toContain("Declined");
|
||
expect(declined.textContent).toContain("use the CRM sync instead");
|
||
expect(declined.textContent).not.toContain("Approve & run");
|
||
});
|
||
|
||
it("renders the expired state without decision controls", () => {
|
||
const host = renderCard({ interaction: expiredToolActionInteraction });
|
||
|
||
expect(host.textContent).toContain("Expired");
|
||
expect(host.textContent).not.toContain("Approve & run");
|
||
});
|
||
|
||
it("keeps the generic confirmation rendering for cards without a toolAction", () => {
|
||
const host = renderCard({
|
||
interaction: pendingRequestConfirmationInteraction,
|
||
onAcceptInteraction: vi.fn(),
|
||
onRejectInteraction: vi.fn(),
|
||
});
|
||
|
||
// Legacy confirmation keeps its own prompt + labels, no tool-action surface.
|
||
expect(host.textContent).toContain("Approve the plan and let the responsible start implementation?");
|
||
expect(host.textContent).not.toContain("Approve & run");
|
||
expect(host.textContent).not.toContain("Technical details");
|
||
});
|
||
|
||
it("renders the addressee chip without the removed policy badge", () => {
|
||
const host = renderCard({
|
||
interaction: agentAddressedRequestConfirmationInteraction,
|
||
});
|
||
|
||
// PAP-440: the "Agents may resolve" policy badge was pure noise — never rendered.
|
||
expect(host.querySelector('[data-testid="interaction-policy-badge"]')).toBeNull();
|
||
|
||
const addresseeBadge = host.querySelector('[data-testid="interaction-addressee-badge"]');
|
||
expect(addresseeBadge?.textContent).toContain("For ");
|
||
});
|
||
|
||
it("omits the addressee badge for a board-only interaction", () => {
|
||
const host = renderCard({
|
||
interaction: pendingRequestConfirmationInteraction,
|
||
});
|
||
|
||
expect(host.querySelector('[data-testid="interaction-policy-badge"]')).toBeNull();
|
||
expect(host.querySelector('[data-testid="interaction-addressee-badge"]')).toBeNull();
|
||
});
|
||
|
||
it("marks agent resolution with an audit chip in the resolved footer", () => {
|
||
const host = renderCard({
|
||
interaction: agentResolvedRequestConfirmationInteraction,
|
||
});
|
||
|
||
const footer = host.querySelector('[data-testid="interaction-resolved-footer"]');
|
||
expect(footer?.textContent).toContain("Resolved by");
|
||
expect(
|
||
host.querySelector('[data-testid="interaction-resolved-by-agent-chip"]'),
|
||
).not.toBeNull();
|
||
});
|
||
|
||
it("renders a withdrawn footer with the withdrawer, reason, and agent chip", () => {
|
||
const host = renderCard({
|
||
interaction: withdrawnRequestConfirmationInteraction,
|
||
});
|
||
|
||
// Header status reads "Withdrawn", not the raw "Cancelled" status.
|
||
expect(host.textContent).toContain("Withdrawn");
|
||
// Withdrawn is a neutral administrative retraction — it must NOT wear the
|
||
// cancelled/rejected costume (rose/red border + XCircle). The shell is muted
|
||
// (border-border), never a rose/red alarm colour (design review R2).
|
||
const cardRoot = host.querySelector("div.rounded-lg.p-5.shadow-none");
|
||
expect(cardRoot?.className).toContain("border-border");
|
||
expect(cardRoot?.className).not.toMatch(/border-(rose|red)/);
|
||
// The header status icon is MinusCircle ("retracted"), never XCircle ("denied").
|
||
const statusIcon = cardRoot?.querySelector("svg");
|
||
expect(statusIcon?.getAttribute("class")).toContain("lucide-circle-minus");
|
||
expect(statusIcon?.getAttribute("class")).not.toContain("lucide-circle-x");
|
||
const footer = host.querySelector('[data-testid="interaction-withdrawn-footer"]');
|
||
expect(footer?.textContent).toContain("Withdrawn by");
|
||
expect(footer?.textContent).toContain("Plan superseded by a newer revision");
|
||
expect(
|
||
footer?.querySelector('[data-testid="interaction-resolved-by-agent-chip"]'),
|
||
).not.toBeNull();
|
||
// The generic "Resolved by" footer must not double-render.
|
||
expect(host.querySelector('[data-testid="interaction-resolved-footer"]')).toBeNull();
|
||
});
|
||
|
||
it("renders an issue-closed expiry footer for terminal auto-expiry", () => {
|
||
const host = renderCard({
|
||
interaction: issueClosedRequestConfirmationInteraction,
|
||
});
|
||
|
||
// Footer is trimmed to just the audit timestamp — the header status badge
|
||
// already carries the "Expired · issue closed" label, so the footer must
|
||
// not restate it.
|
||
const footer = host.querySelector('[data-testid="interaction-issue-closed-footer"]');
|
||
expect(footer?.textContent).toContain("Apr 20");
|
||
expect(footer?.textContent).not.toContain("Expired when the issue closed");
|
||
// The "Expired · issue closed" label survives exactly once (the header
|
||
// status badge); the duplicate body eyebrow was dropped.
|
||
const label = "Expired · issue closed";
|
||
const occurrences = (host.textContent ?? "").split(label).length - 1;
|
||
expect(occurrences).toBe(1);
|
||
});
|
||
|
||
});
|
||
|
||
describe("IssueThreadInteractionCard secret-proposal card", () => {
|
||
it("renders only safe proposal metadata and exposes accept/reject actions", async () => {
|
||
const onAcceptInteraction = vi.fn(async () => undefined);
|
||
const onRejectInteraction = vi.fn(async () => undefined);
|
||
const host = renderCard({
|
||
interaction: pendingSecretProposalInteraction,
|
||
onAcceptInteraction,
|
||
onRejectInteraction,
|
||
});
|
||
|
||
expect(host.textContent).toContain("Secret binding requested");
|
||
expect(host.textContent).toContain("OpenAI API key");
|
||
expect((host.textContent ?? "").split("OpenAI API key")).toHaveLength(2);
|
||
expect(host.textContent).toContain("access.evals_openai_api_key");
|
||
expect(host.textContent).toContain("EvalsEngineer");
|
||
expect(host.textContent).toContain("Reason given by the agent");
|
||
expect(host.textContent).toContain("evaluation runner needs the existing credential");
|
||
expect(host.textContent).toContain("Expires");
|
||
expect(host.textContent).not.toContain(
|
||
pendingSecretProposalInteraction.payload.secretProposal?.proposalId,
|
||
);
|
||
expect(host.textContent).not.toContain(
|
||
pendingSecretProposalInteraction.payload.secretProposal?.targetAgentId,
|
||
);
|
||
expect(host.textContent?.toLowerCase()).not.toContain("fingerprint");
|
||
|
||
const statusBadge = host.querySelector('[data-testid="interaction-status-badge"]');
|
||
expect(statusBadge?.querySelector(".flex-col")?.textContent).toBe(
|
||
"Secret binding/Awaiting approval",
|
||
);
|
||
expect(statusBadge?.querySelector(".hidden")?.textContent).toBe("/");
|
||
const actions = host.querySelector('[data-testid="confirmation-actions"]');
|
||
expect(actions?.getAttribute("data-mobile-layout")).toBe("stacked");
|
||
expect(actions?.classList.contains("grid-cols-2")).toBe(true);
|
||
const configPath = Array.from(host.querySelectorAll("dd")).find((node) =>
|
||
node.textContent === "access.evals_openai_api_key"
|
||
);
|
||
expect(configPath?.parentElement?.classList.contains("sm:col-span-2")).toBe(true);
|
||
|
||
const approve = Array.from(host.querySelectorAll("button")).find((button) =>
|
||
button.textContent?.includes("Approve & bind"),
|
||
);
|
||
await act(async () => {
|
||
approve?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
expect(onAcceptInteraction).toHaveBeenCalledWith(pendingSecretProposalInteraction);
|
||
|
||
const reject = Array.from(host.querySelectorAll("button")).find((button) =>
|
||
button.textContent?.trim() === "Reject",
|
||
);
|
||
await act(async () => {
|
||
reject?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||
});
|
||
expect(onRejectInteraction).toHaveBeenCalledWith(
|
||
pendingSecretProposalInteraction,
|
||
undefined,
|
||
);
|
||
});
|
||
|
||
it("renders an accepted proposal as executed rather than merely accepted", () => {
|
||
const host = renderCard({ interaction: executedSecretProposalInteraction });
|
||
expect(host.textContent).toContain("Executed");
|
||
expect(host.textContent).toContain("Binding created");
|
||
expect(host.textContent).not.toContain("Accepted");
|
||
expect(host.querySelector("button")).toBeNull();
|
||
});
|
||
|
||
it("renders accepted execution failure as visibly FAILED with its error code", () => {
|
||
const host = renderCard({ interaction: failedSecretProposalInteraction });
|
||
expect(host.textContent).toContain("FAILED");
|
||
expect(host.textContent).toContain("binding_snapshot_stale");
|
||
expect(host.textContent).toContain("binding was not created");
|
||
expect(host.textContent).not.toContain("Approve & bind");
|
||
});
|
||
|
||
it("distinguishes rejected and expired proposals as non-executed terminal states", () => {
|
||
const rejected = renderCard({ interaction: rejectedSecretProposalInteraction });
|
||
expect(rejected.textContent).toContain("Rejected");
|
||
expect(rejected.textContent).toContain("The binding was not created");
|
||
expect(rejected.textContent).toContain("project-scoped credential");
|
||
|
||
act(() => root?.unmount());
|
||
rejected.remove();
|
||
root = null;
|
||
|
||
const expired = renderCard({ interaction: expiredSecretProposalInteraction });
|
||
expect(expired.textContent).toContain("Expired");
|
||
expect(expired.textContent).toContain("A fresh proposal is required");
|
||
expect(expired.textContent).not.toContain("Approve & bind");
|
||
});
|
||
});
|
||
|
||
/**
|
||
* Connection authorization has its own card composition (PAP-17796 Surface F,
|
||
* corrected in PAP-17859). It must not fall through to the generic
|
||
* Approve / Revise… / Reject layout: there is nothing to revise, and only the
|
||
* addressed person can answer at all.
|
||
*/
|
||
describe("IssueThreadInteractionCard connection-authorization card", () => {
|
||
const CAROL_LABELS = new Map([
|
||
[issueThreadInteractionFixtureMeta.currentUserId, "Carol"],
|
||
]);
|
||
|
||
function buttonLabels(host: HTMLElement) {
|
||
return Array.from(host.querySelectorAll("button")).map((button) => button.textContent?.trim());
|
||
}
|
||
|
||
it("offers the addressed person Connect plus Not now, and nothing from the generic grammar", () => {
|
||
const host = renderCard({
|
||
interaction: pendingConnectionAuthorizationInteraction,
|
||
currentUserId: issueThreadInteractionFixtureMeta.currentUserId,
|
||
onAcceptInteraction: async () => {},
|
||
onRejectInteraction: async () => {},
|
||
});
|
||
|
||
expect(host.textContent).toContain("Connect your Gmail to continue");
|
||
|
||
// "Action required" rather than the generic kind label: the mechanism is
|
||
// not the point, the blocked work is.
|
||
const statusBadge = host.querySelector('[data-testid="interaction-status-badge"]');
|
||
expect(statusBadge?.textContent).toContain("Action required");
|
||
expect(statusBadge?.textContent).not.toContain("Confirmation");
|
||
|
||
// One title, one body. The generic layout rendered `payload.prompt` too,
|
||
// which is the title verbatim.
|
||
const titles = (host.textContent ?? "").split("Connect your Gmail to continue").length - 1;
|
||
expect(titles).toBe(1);
|
||
const body = host.querySelector('[data-testid="connection-authorization-body"]');
|
||
expect(host.querySelectorAll('[data-testid="connection-authorization-body"]').length).toBe(1);
|
||
expect(body?.textContent).toContain(
|
||
"Outreach Agent needs your Gmail identity for work running as you.",
|
||
);
|
||
expect(body?.textContent).toContain("No one else can complete this step.");
|
||
|
||
// The primary action is a link to the server-minted target, not an accept
|
||
// call: the OAuth callback is what resolves this card.
|
||
const connect = Array.from(host.querySelectorAll("a")).find((anchor) =>
|
||
anchor.textContent?.includes("Connect Gmail"),
|
||
);
|
||
expect(connect?.getAttribute("href")).toBe(
|
||
"https://accounts.google.com/o/oauth2/v2/auth?client_id=paperclip",
|
||
);
|
||
expect(connect?.getAttribute("target")).toBe("_blank");
|
||
|
||
const labels = buttonLabels(host);
|
||
expect(labels).toContain("Not now");
|
||
for (const generic of ["Approve", "Revise…", "Reject", "Send revision"]) {
|
||
expect(labels).not.toContain(generic);
|
||
}
|
||
|
||
// Consent is the addressed person's alone. The card must never imply a
|
||
// teammate can give it for them.
|
||
expect(host.textContent?.toLowerCase()).not.toContain("on behalf of");
|
||
expect(host.textContent?.toLowerCase()).not.toContain("anyone can");
|
||
});
|
||
|
||
it("gives another reader Waiting for Carol and no action controls at all", () => {
|
||
const host = renderCard({
|
||
interaction: pendingConnectionAuthorizationInteraction,
|
||
currentUserId: "user-someone-else",
|
||
userLabelMap: CAROL_LABELS,
|
||
onAcceptInteraction: async () => {},
|
||
onRejectInteraction: async () => {},
|
||
});
|
||
|
||
expect(host.querySelector('[data-testid="connection-authorization-waiting"]')?.textContent)
|
||
.toContain("Waiting for Carol");
|
||
expect(host.querySelector('[data-testid="interaction-status-badge"]')?.textContent)
|
||
.toContain("Waiting for Carol");
|
||
// The server's summary is second-person ("your Gmail identity", "as you")
|
||
// because it addressed one person. A teammate must not be told the agent
|
||
// wants *their* account.
|
||
const body = host.querySelector('[data-testid="connection-authorization-body"]')?.textContent;
|
||
expect(body).toBe(
|
||
"Outreach Agent needs Carol's Gmail identity for work running as them.",
|
||
);
|
||
expect(host.querySelector('[data-testid="connection-authorization-waiting"]')?.textContent)
|
||
.toContain("Only Carol can connect their own Gmail account.");
|
||
|
||
// Omitted, not disabled — and the authorization URL is not even in the DOM
|
||
// for someone who may not use it.
|
||
const labels = buttonLabels(host);
|
||
for (const forbidden of ["Connect Gmail", "Not now", "Approve", "Revise…", "Reject"]) {
|
||
expect(labels).not.toContain(forbidden);
|
||
}
|
||
expect(
|
||
Array.from(host.querySelectorAll("a")).some((a) => a.textContent?.includes("Connect Gmail")),
|
||
).toBe(false);
|
||
expect(host.innerHTML).not.toContain("accounts.google.com");
|
||
});
|
||
|
||
it("resolves to Gmail connected with the resolver and a timestamp", () => {
|
||
const host = renderCard({
|
||
interaction: resolvedConnectionAuthorizationInteraction,
|
||
currentUserId: "user-someone-else",
|
||
userLabelMap: CAROL_LABELS,
|
||
onAcceptInteraction: async () => {},
|
||
onRejectInteraction: async () => {},
|
||
});
|
||
|
||
const statusBadge = host.querySelector('[data-testid="interaction-status-badge"]');
|
||
expect(statusBadge?.textContent).toContain("Gmail connected");
|
||
expect(statusBadge?.textContent).not.toContain("Action required");
|
||
|
||
const connected = host.querySelector('[data-testid="connection-authorization-connected"]');
|
||
expect(connected?.textContent).toContain("Gmail connected");
|
||
expect(connected?.textContent).toContain("Connected by Carol");
|
||
expect(connected?.textContent).toMatch(/on .*2026/);
|
||
expect(host.querySelector('[data-testid="connection-authorization-body"]')?.textContent)
|
||
.toBe("Outreach Agent needed Carol's Gmail identity for work running as them.");
|
||
|
||
// A resolved card never re-offers the spent authorization target.
|
||
expect(host.innerHTML).not.toContain("accounts.google.com");
|
||
expect(buttonLabels(host)).not.toContain("Connect Gmail");
|
||
// The card states its own resolver, so the shared footer must not repeat it.
|
||
expect(host.querySelector('[data-testid="interaction-resolved-footer"]')).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe("IssueThreadInteractionCard connection-intent card", () => {
|
||
const USER_LABELS = new Map([[issueThreadInteractionFixtureMeta.currentUserId, "Carol"]]);
|
||
|
||
it("offers the addressed user the shared setup entry point and Not now", () => {
|
||
const host = renderCard({
|
||
interaction: pendingConnectionIntentInteraction,
|
||
currentUserId: issueThreadInteractionFixtureMeta.currentUserId,
|
||
});
|
||
expect(host.querySelector('[data-testid="connection-intent-actions"]')).not.toBeNull();
|
||
const labels = Array.from(host.querySelectorAll("button")).map((button) => button.textContent?.trim());
|
||
expect(labels).toContain("Connect");
|
||
expect(labels).toContain("Not now");
|
||
expect(host.textContent).toContain("Access is added only for this agent");
|
||
});
|
||
|
||
it("shows another viewer only who it is waiting for", () => {
|
||
const host = renderCard({
|
||
interaction: pendingConnectionIntentInteraction,
|
||
currentUserId: "user-someone-else",
|
||
userLabelMap: USER_LABELS,
|
||
});
|
||
expect(host.querySelector('[data-testid="connection-intent-waiting"]')?.textContent)
|
||
.toContain("Waiting for Carol");
|
||
expect(host.querySelector('[data-testid="connection-intent-actions"]')).toBeNull();
|
||
expect(host.querySelectorAll("button")).toHaveLength(0);
|
||
expect(host.innerHTML).not.toContain("authorizationUrl");
|
||
});
|
||
|
||
it("renders retry and connected terminal states without generic confirmation actions", () => {
|
||
const retry = renderCard({
|
||
interaction: retryConnectionIntentInteraction,
|
||
currentUserId: issueThreadInteractionFixtureMeta.currentUserId,
|
||
});
|
||
expect(retry.textContent).toContain("Authorization didn’t finish");
|
||
expect(retry.textContent).toContain("Try again");
|
||
|
||
act(() => root?.unmount());
|
||
retry.remove();
|
||
root = null;
|
||
const connected = renderCard({
|
||
interaction: connectedConnectionIntentInteraction,
|
||
currentUserId: issueThreadInteractionFixtureMeta.currentUserId,
|
||
});
|
||
expect(connected.querySelector('[data-testid="connection-intent-terminal"]')?.textContent)
|
||
.toContain("Notion connected");
|
||
expect(connected.textContent).not.toContain("Approve");
|
||
});
|
||
});
|
||
|
||
describe("IssueThreadInteractionCard resolver audience", () => {
|
||
it("shows an open audience on a pending card created without a restriction", () => {
|
||
const host = renderCard({ interaction: pendingRequestConfirmationInteraction });
|
||
|
||
const audience = host.querySelector('[data-testid="interaction-audience"]');
|
||
expect(audience?.getAttribute("data-audience-policy")).toBe("anyone");
|
||
expect(audience?.getAttribute("data-audience-open")).toBe("true");
|
||
expect(audience?.textContent).toContain("Anyone");
|
||
expect(audience?.textContent).toContain("the board or any agent, including the one that asked");
|
||
// An open card must never read as board-required.
|
||
expect(audience?.textContent).not.toMatch(/only a person on the board/i);
|
||
expect(host.querySelector('[data-testid="interaction-audience-note"]')).toBeNull();
|
||
});
|
||
|
||
it("names the excluded creator for an explicit not_creator card", () => {
|
||
const host = renderCard({
|
||
interaction: notCreatorRequestConfirmationInteraction,
|
||
agentMap: new Map([["agent-codex", { name: "CodexCoder" } as Agent]]),
|
||
});
|
||
|
||
const audience = host.querySelector('[data-testid="interaction-audience"]');
|
||
expect(audience?.getAttribute("data-audience-policy")).toBe("not_creator");
|
||
expect(audience?.getAttribute("data-audience-open")).toBe("false");
|
||
expect(audience?.textContent).toContain("Anyone except creator");
|
||
expect(audience?.textContent).toContain("except CodexCoder can respond");
|
||
});
|
||
|
||
it("keeps human-only ownership copy on a human-only card", () => {
|
||
const host = renderCard({ interaction: humanOnlyRequestConfirmationInteraction });
|
||
|
||
const audience = host.querySelector('[data-testid="interaction-audience"]');
|
||
expect(audience?.getAttribute("data-audience-policy")).toBe("human_only");
|
||
expect(audience?.textContent).toContain("Human only");
|
||
expect(audience?.textContent).toContain("Only a person on the board can respond");
|
||
});
|
||
|
||
it("keeps addressee ownership copy on an agent-addressed card", () => {
|
||
const host = renderCard({
|
||
interaction: agentAddressedRequestConfirmationInteraction,
|
||
agentMap: new Map([["agent-codex", { name: "CodexCoder" } as Agent]]),
|
||
});
|
||
|
||
const audience = host.querySelector('[data-testid="interaction-audience"]');
|
||
expect(audience?.getAttribute("data-audience-open")).toBe("false");
|
||
expect(audience?.textContent).toContain("Addressed");
|
||
expect(audience?.textContent).toContain("Only CodexCoder or a person on the board can respond");
|
||
expect(audience?.textContent).not.toContain("Anyone");
|
||
});
|
||
|
||
it("explains a company cap that narrowed the requested audience", () => {
|
||
const host = renderCard({ interaction: companyCappedRequestConfirmationInteraction });
|
||
|
||
const audience = host.querySelector('[data-testid="interaction-audience"]');
|
||
expect(audience?.getAttribute("data-audience-policy")).toBe("human_only");
|
||
expect(
|
||
host.querySelector('[data-testid="interaction-audience-note"]')?.textContent,
|
||
).toBe("Organization interaction governance narrowed this from Anyone to Human only.");
|
||
});
|
||
|
||
it("explains a legacy card that predates the open default", () => {
|
||
const host = renderCard({ interaction: legacyRestrictedRequestConfirmationInteraction });
|
||
|
||
expect(
|
||
host.querySelector('[data-testid="interaction-audience-note"]')?.textContent,
|
||
).toContain("Created before Anyone became the default");
|
||
});
|
||
|
||
it("omits the audience row once a card is resolved and shows who resolved it", () => {
|
||
const host = renderCard({
|
||
interaction: agentResolvedRequestConfirmationInteraction,
|
||
agentMap: new Map([["agent-codex", { name: "CodexCoder" } as Agent]]),
|
||
});
|
||
|
||
expect(host.querySelector('[data-testid="interaction-audience"]')).toBeNull();
|
||
const footer = host.querySelector('[data-testid="interaction-resolved-footer"]');
|
||
expect(footer?.textContent).toContain("Resolved by");
|
||
expect(footer?.textContent).toContain("CodexCoder");
|
||
expect(
|
||
footer?.querySelector('[data-testid="interaction-resolved-by-agent-chip"]'),
|
||
).not.toBeNull();
|
||
});
|
||
});
|