Files
PaperClipAI/ui/src/components/task-chat/TaskChatProtocolCard.test.tsx
T
DottaandPaperclip 42a4f5b15b feat(ui): advance single-choice questions on selection (#13234)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agents ask structured questions in task threads and the composer.
> - The shared form shows one question at a time.
> - A single choice already completes an answer, but the form requires
another click on Next.
> - This pull request moves to the next question when the user selects
one option.
> - Multi-select and custom answers keep their Next step. The last page
keeps explicit submission.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

The paged question form in the task composer and task interaction cards.

**Subsystem affected**

ui/ — React board UI.

**Current behavior**

The user clicks a single choice, then clicks Next to reach the next
question.

**Proposed behavior**

A single choice shows its checked state for 160 ms, then opens the next
question with an 80 ms fade. Reduced-motion mode skips the animation.
Multi-select stays on the current page until Next. Other stays open for
typing. The last question waits for Submit answers.

**Reason and benefit**

Remove an extra click from each single-choice question while preserving
explicit submission.

**Breaking changes**

Single-choice selection now changes the page. Answer payloads and APIs
do not change. The user can return to earlier answers with the previous
arrow.

**Additional context**

Related work: #12640 introduced the task workspace. No duplicate
auto-advance change was found.

## What Changed

- Confirm a single-choice answer with a brief radio animation and row
highlight, then fade into the next page and focus the new question.
- Use motion tokens for the 160 ms confirmation and 80 ms page fade.
Honor reduced motion and cancel pending advances when the user changes
direction or closes the form.
- Lightly highlight every selected row with a foreground tint that
remains visible against the composer in both themes, for single-select
and multi-select answers.
- Preserve multi-select, custom answers, back navigation, and final
submission.
- Ignore repeated number-key events and selection during an upload or
submission.
- Update composer and card tests. Add interactive and verified composer
stories to the existing interaction Storybook group.
- Document the Storybook scenario in the developer guide.

## Verification

- Focused composer, card, and motion-catalog tests: 121 passed,
including animation timing and cancellation.
- `pnpm check:token-gates`: passed.
- `pnpm build-storybook`: passed. Browser interaction story: passed with
the selection animation enabled.
- Selected-row refinement: 121 focused tests, token gates, and Storybook
build passed. Browser inspection confirmed row highlighting for
single-select and multiple selected checkboxes, plus light-theme
contrast.
- Manual browser check: select SQLite, select two features, click Next,
select Now, then submit. The summary contains every answer.
- `pnpm -r typecheck`: passed.
- `pnpm build`: passed.
- All 31 remote checks passed on the latest commit (`04a3f3983`),
including typecheck, general and serialized tests, production
build/native-runner verification, canary validation, and browser tests.
Two optional Storybook deployment/visual jobs were skipped. Greptile
reviewed the same commit at 5/5 with no actionable findings. The
selected-row highlight refinement is included in that verification.
- Animation refinement: UI typecheck, UI build, token gates, and 121
focused tests passed.
- The broader UI run had 5,940 passing tests and three failures in
unchanged Inbox/IssuesList tests. Both affected suites passed in
isolation (73/73), including all three previously failing cases.
- The broad local `pnpm test:run` was stopped after reproducing the
native-runner failure below and after the full remote suite passed. It
is not a clean local full-suite result.
- Local runner limitation:
`server/src/services/native-runtime/native-session-resume.test.ts` has
one reproducible failure in unchanged code. The damaged-epoch recovery
test expects `run.attach requires a settled Codex provider session`; the
runner instead reports `semantic tool input content digest does not
match its transmitted input` at line 1019. After building the missing
fake provider with `pnpm --filter @paperclipai/paperclip-runner
build:rust`, the isolated suite has 36 passing tests and this one
failure. No server or runner files changed in this PR.

## Risks

- Selecting a single choice changes the visible question after a brief
checked-state confirmation. Back navigation preserves the choice so it
can be edited.
- The final page still requires Submit answers. Selecting Other still
requires text and explicit progress.
- No database, server, API, or dependency changes.

## Model Used

- OpenAI GPT-6 in Codex, with reasoning, code editing, terminal tools,
and browser verification. 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
- [ ] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-09-11 11:59:24 -05:00

875 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
import { act, forwardRef, useImperativeHandle, 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";
import { ThemeProvider } from "@/context/ThemeContext";
import { MemoryRouter } from "@/lib/router";
import { TaskChatProtocolCard } from "./TaskChatProtocolCard";
import type {
TaskChatProtocolItem,
TaskChatProviderActivityFamily,
TaskChatRuntimeRequestDecision,
} from "./task-chat-model";
import type { IssueWorkProduct } from "@paperclipai/shared";
import { IssueGalleryContext } from "@/context/IssueGalleryContext";
import { RichWorkProductCard } from "./RichWorkProductCard";
import { stateChipFor } from "./RichWorkProductCard";
function workProduct(overrides: Partial<IssueWorkProduct> = {}): IssueWorkProduct {
return {
id: "work-product-1",
companyId: "company-1",
projectId: null,
issueId: "issue-1",
executionWorkspaceId: null,
runtimeServiceId: null,
type: "pull_request",
provider: "github",
externalId: "42",
title: "Ship rich work-product cards",
url: "https://github.com/paperclipai/paperclip/pull/42",
status: "merged",
reviewState: "none",
isPrimary: true,
healthStatus: "healthy",
summary: null,
metadata: null,
createdByRunId: null,
createdAt: new Date("2026-09-02T00:00:00.000Z"),
updatedAt: new Date("2026-09-02T00:00:00.000Z"),
...overrides,
};
}
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)}
/>
);
}),
}));
function renderCard(
root: Root,
item: TaskChatProtocolItem,
onDecision?: (
decision: TaskChatRuntimeRequestDecision,
) => void | Promise<void>,
presentation: "timeline" | "takeover" = "timeline",
) {
flushSync(() =>
root.render(
<MemoryRouter>
<ThemeProvider>
<TaskChatProtocolCard
item={item}
presentation={presentation}
imageUploadHandler={async () => "blob:question-image"}
onRuntimeRequestDecision={
onDecision
? (_request, decision) => onDecision(decision)
: undefined
}
/>
</ThemeProvider>
</MemoryRouter>,
),
);
}
describe("TaskChatProtocolCard", () => {
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 a rich deliverable card without a completed chip", () => {
const product = workProduct({
metadata: {
repo: "paperclipai/paperclip",
number: 42,
baseRef: "master",
headRef: "feat/rich-cards",
additions: 17,
deletions: 5,
changedFiles: 3,
state: "merged",
draft: false,
},
});
renderCard(root, {
id: "resource:deliverable:work-product-1",
kind: "protocol",
surface: "resource",
resourceKind: "deliverable",
title: product.title,
subtitle: "pull request · merged",
href: product.url,
workProduct: product,
});
expect(container.querySelector('[data-testid="task-chat-rich-work-product-pull_request"]')).not.toBeNull();
expect(container.textContent).toContain("Open on GitHub");
expect(container.textContent).toContain("paperclipai/paperclip · #42 · master ← feat/rich-cards");
expect(container.textContent).toContain("+17 −5 · 3 files");
expect(container.textContent).toContain("Merged");
expect(container.textContent).not.toContain("Completed");
const action = container.querySelector('a[aria-label="Open on GitHub: Ship rich work-product cards"]');
expect(action?.querySelector("span")?.className).toContain("hidden @sm:inline");
const stats = Array.from(container.querySelectorAll("p")).find((node) => node.textContent === "+17 −5 · 3 files");
expect(stats?.className).toContain("whitespace-nowrap");
});
it("shows pending artifacts with a dashed Pending chip", () => {
const product = workProduct({
type: "artifact",
provider: "paperclip",
url: "/api/attachments/attachment-1/content",
status: "pending",
title: "demo.png",
metadata: { contentType: "image/png", byteSize: 2048 },
});
renderCard(root, {
id: "resource:deliverable:work-product-1",
kind: "protocol",
surface: "resource",
resourceKind: "deliverable",
title: product.title,
subtitle: "artifact · pending",
href: product.url,
workProduct: product,
});
const chip = Array.from(container.querySelectorAll("span")).find((node) => node.textContent === "Pending");
expect(chip?.className).toContain("border-dashed");
expect(container.textContent).toContain("Image · 2.0 KB");
expect(container.textContent).toContain("Open gallery");
});
it.each(["image/png", "video/webm"])("opens %s artifacts in the task gallery", (contentType) => {
const openGallery = vi.fn(() => true);
const contentPath = "/api/attachments/media/content";
flushSync(() => root.render(
<IssueGalleryContext.Provider value={openGallery}>
<RichWorkProductCard
workProduct={workProduct({ type: "artifact", metadata: { contentType, contentPath } })}
href={contentPath}
variant="compact"
/>
</IssueGalleryContext.Provider>,
));
const button = container.querySelector<HTMLButtonElement>('button[aria-label^="Open gallery:"]');
expect(button).not.toBeNull();
expect(container.querySelector("a")).toBeNull();
flushSync(() => button!.click());
expect(openGallery).toHaveBeenCalledWith(contentPath);
expect(document.querySelector('[role="dialog"]')).toBeNull();
});
it("opens standalone artifact media in a modal with a download", async () => {
const contentPath = "/api/attachments/media/content";
flushSync(() => root.render(
<RichWorkProductCard
workProduct={workProduct({ type: "artifact", title: "Screenshot", metadata: { contentType: "image/png", contentPath, originalFilename: "proof.png", downloadPath: `${contentPath}?download=1` } })}
href={contentPath}
/>,
));
await act(async () => container.querySelector<HTMLButtonElement>('button[aria-label="Open gallery: Screenshot"]')!.click());
expect(document.querySelector('[role="dialog"] img')?.getAttribute("src")).toBe(contentPath);
expect(document.querySelector('a[aria-label="Download proof.png"]')?.getAttribute("href")).toBe(`${contentPath}?download=1`);
await act(async () => document.querySelector<HTMLButtonElement>('button[title="Close"]')!.click());
expect(document.querySelector('[role="dialog"]')).toBeNull();
});
it("keeps completed and approved states out of the state-chip policy", () => {
expect(stateChipFor("commit", "completed", "none")).toBeNull();
expect(stateChipFor("document", "approved", "approved")).toBeNull();
expect(stateChipFor("pull_request", "open", "none")).toMatchObject({ label: "Open", tone: "progress" });
expect(stateChipFor("pull_request", "merged", "none")).toMatchObject({ label: "Merged", tone: "success" });
expect(stateChipFor("pull_request", "closed", "none")).toMatchObject({ label: "Closed", tone: "neutral" });
expect(stateChipFor("artifact", "pending", "none")).toMatchObject({ label: "Pending", dashed: true });
});
it("shows the live open pull request state while preserving explicit review-state precedence", () => {
const product = workProduct({
status: "ready_for_review",
metadata: { state: "open" },
});
renderCard(root, {
id: "resource:deliverable:work-product-1",
kind: "protocol",
surface: "resource",
resourceKind: "deliverable",
title: product.title,
subtitle: "pull request · open",
href: product.url,
workProduct: product,
});
const chip = Array.from(container.querySelectorAll("span")).find((node) => node.textContent === "Open");
expect(chip).toBeDefined();
expect(container.textContent).not.toContain("Review");
expect(stateChipFor("pull_request", "open", "needs_board_review")).toMatchObject({
label: "Review",
tone: "review",
});
});
it("shows an unhealthy active runtime as unhealthy", () => {
const product = workProduct({
type: "runtime_service",
provider: "paperclip",
status: "active",
healthStatus: "unhealthy",
title: "Storybook",
metadata: { service: "storybook", port: 6006 },
});
renderCard(root, {
id: "resource:deliverable:work-product-1",
kind: "protocol",
surface: "resource",
resourceKind: "deliverable",
title: product.title,
subtitle: "runtime service · active",
href: product.url,
workProduct: product,
});
expect(container.textContent).toContain("Unhealthy");
expect(container.textContent).not.toContain("Running");
});
it("does not show running for an active runtime with unknown health", () => {
const product = workProduct({
type: "runtime_service",
provider: "paperclip",
status: "active",
healthStatus: "unknown",
title: "Storybook",
metadata: { service: "storybook", port: 6006 },
});
renderCard(root, {
id: "resource:deliverable:work-product-1",
kind: "protocol",
surface: "resource",
resourceKind: "deliverable",
title: product.title,
subtitle: "runtime service · active",
href: product.url,
workProduct: product,
});
expect(container.textContent).not.toContain("Running");
expect(container.textContent).not.toContain("Unhealthy");
});
it("shows an unhealthy runtime with a non-standard open status as unhealthy", () => {
const product = workProduct({
type: "runtime_service",
provider: "paperclip",
status: "open",
healthStatus: "unhealthy",
title: "Storybook",
metadata: { service: "storybook", port: 6006 },
});
renderCard(root, {
id: "resource:deliverable:work-product-1",
kind: "protocol",
surface: "resource",
resourceKind: "deliverable",
title: product.title,
subtitle: "runtime service · open",
href: product.url,
workProduct: product,
});
expect(container.textContent).toContain("Unhealthy");
expect(container.textContent).not.toContain("Running");
});
it("shows a closed runtime as stopped even when its last health check failed", () => {
const product = workProduct({
type: "runtime_service",
provider: "paperclip",
status: "closed",
healthStatus: "unhealthy",
title: "Storybook",
metadata: { service: "storybook", port: 6006 },
});
renderCard(root, {
id: "resource:deliverable:work-product-1",
kind: "protocol",
surface: "resource",
resourceKind: "deliverable",
title: product.title,
subtitle: "runtime service · closed",
href: product.url,
workProduct: product,
});
expect(container.textContent).toContain("Stopped");
expect(container.textContent).not.toContain("Unhealthy");
});
it("renders provider plan steps and status", () => {
renderCard(root, {
id: "provider-plan",
kind: "protocol",
surface: "provider_activity",
family: "plan",
eventType: "plan.updated",
status: "running",
title: "Plan",
summary: "Implement protocol surfaces",
details: [],
links: [],
children: [],
steps: [
{ id: "one", label: "Inventory events", status: "completed" },
{ id: "two", label: "Render widgets", status: "in_progress" },
],
});
expect(
container.querySelector('[data-testid="task-chat-provider-plan"]'),
).not.toBeNull();
expect(container.textContent).toContain("Inventory events");
expect(container.textContent).toContain("Render widgets");
});
it("renders a structured history card for every provider family", () => {
const families = [
"plan",
"tool_execution",
"research",
"delegation",
"model_identity",
"context",
"artifact",
"review",
"hook",
"memory",
"safety",
"terminal",
"wait",
"provider_notice",
] satisfies TaskChatProviderActivityFamily[];
for (const family of families) {
renderCard(root, {
id: `provider-${family}`,
kind: "protocol",
surface: "provider_activity",
family,
eventType: `${family}.fixture`,
status: "completed",
title: `Visible ${family}`,
summary: `Summary ${family}`,
details: [{ label: "Reference", value: `${family}-1` }],
links: [],
children: [],
steps: [],
});
expect(
container.querySelector(`[data-testid="task-chat-provider-${family}"]`),
family,
).not.toBeNull();
expect(container.textContent, family).toContain(`Visible ${family}`);
}
});
it("opens a workspace diff review dialog", async () => {
renderCard(root, {
id: "workspace",
kind: "protocol",
surface: "workspace_change",
changeSetId: "changes-1",
revision: 1,
source: "runner_verified",
complete: true,
files: [
{
path: "ui/src/App.tsx",
operation: "modify",
previousPath: null,
additions: 1,
deletions: 1,
binary: false,
diff: "-old\n+new",
},
],
totals: { files: 1, additions: 1, deletions: 1 },
patchArtifactRef: null,
});
const button = Array.from(container.querySelectorAll("button")).find(
(candidate) => candidate.textContent === "Review diff",
);
expect(button).not.toBeUndefined();
expect(container.textContent).toContain("1 file changed · +1 −1");
await act(async () => button?.click());
expect(document.querySelector('[role="dialog"]')?.textContent).toContain(
"ui/src/App.tsx",
);
expect(document.querySelector('[role="dialog"]')?.textContent).toContain(
"old",
);
});
it("opens a bounded file preview", async () => {
renderCard(root, {
id: "file",
kind: "protocol",
surface: "workspace_file",
referenceId: "file-1",
source: "runner_verified",
path: "doc/protocol.md",
displayName: "protocol.md",
mediaType: "text/markdown",
presentation: "document",
line: 12,
preview: "# Protocol preview",
previewTruncated: false,
});
const button = Array.from(container.querySelectorAll("button")).find(
(candidate) => candidate.textContent === "Preview",
);
await act(async () => button?.click());
expect(document.querySelector('[role="dialog"]')?.textContent).toContain(
"Protocol preview",
);
});
it("submits a runtime choice only when a resolver is supplied", async () => {
const onDecision = vi.fn();
renderCard(
root,
{
id: "request",
kind: "protocol",
surface: "runtime_request",
runId: "run-1",
requestId: "request-1",
requestKind: "command_approval",
turnId: "turn-1",
requestType: "permission",
status: "pending",
prompt: "Allow command?",
choices: [{ key: "accept", label: "Allow once" }],
fields: [],
},
onDecision,
);
const button = Array.from(container.querySelectorAll("button")).find(
(candidate) => candidate.textContent === "Allow once",
);
expect(button?.disabled).toBe(false);
await act(async () => button?.click());
expect(onDecision).toHaveBeenCalledWith({ action: "accept" });
});
it("submits structured runtime input through the production card", async () => {
const onDecision = vi.fn().mockResolvedValue(undefined);
renderCard(
root,
{
id: "input-request",
kind: "protocol",
surface: "runtime_request",
runId: "run-1",
requestId: "request-input",
requestKind: "user_input",
turnId: "turn-1",
requestType: "input",
status: "pending",
prompt: "Which environment should the run target?",
choices: [],
fields: [
{ name: "environment", label: "Environment", placeholder: "staging" },
],
},
onDecision,
);
const textarea = container.querySelector("textarea")!;
await act(async () => {
const setter = Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
"value",
)?.set;
setter?.call(textarea, "production");
textarea.dispatchEvent(new Event("input", { bubbles: true }));
});
const submit = Array.from(container.querySelectorAll("button")).find(
(candidate) => candidate.textContent === "Submit response",
);
await act(async () => submit?.click());
expect(onDecision).toHaveBeenCalledWith({
action: "submit",
values: { environment: "production" },
});
expect(container.textContent).toContain("Submitting…");
});
it("submits the canonical response from a v2 harness question set", async () => {
const onDecision = vi.fn().mockResolvedValue(undefined);
renderCard(
root,
{
id: "canonical-input-request",
kind: "protocol",
surface: "runtime_request",
runId: "run-1",
requestId: "request-canonical-input",
requestKind: "runtime",
turnId: "turn-1",
requestType: "input",
status: "pending",
prompt: "Codex needs your input.",
choices: [],
fields: [],
questionSet: {
schema: "paperclip.question_set.v1",
title: "Deployment input",
submitLabel: "Continue",
questions: [
{
id: "environment",
header: "Environment",
prompt: "Where should we deploy?",
required: true,
answerMode: "single_select",
options: [
{ id: "staging", label: "Staging" },
{ id: "production", label: "Production" },
],
},
{
id: "regions",
header: "Regions",
prompt: "Which regions should receive the release?",
required: false,
answerMode: "multi_select",
options: [
{ id: "us", label: "US" },
{ id: "eu", label: "EU" },
],
},
{
id: "notes",
header: "Notes",
prompt: "Anything else we should know?",
required: false,
answerMode: "text",
},
],
},
},
onDecision,
);
expect(container.textContent).toContain("Deployment input");
expect(container.textContent).toContain("1 of 3");
const production = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("Production"),
);
await act(async () => production?.click());
// Single selection advances; multi-selection waits for Next.
const nextButton = () =>
Array.from(container.querySelectorAll<HTMLButtonElement>("button")).find(
(button) => button.textContent?.trim() === "Next",
);
expect(container.textContent).toContain("2 of 3");
expect(onDecision).not.toHaveBeenCalled();
expect(container.textContent).toContain(
"Which regions should receive the release?",
);
expect(
Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "Continue",
),
).toBeUndefined();
await act(async () => nextButton()?.click());
expect(container.textContent).toContain("Anything else we should know?");
const submit = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "Continue",
);
await act(async () => submit?.click());
expect(onDecision).toHaveBeenCalledWith({
action: "submit",
response: {
schema: "paperclip.question_response.v1",
answers: { environment: { selectedOptionIds: ["production"] } },
},
});
});
it("renders structured runtime questions directly in the takeover without duplicate request copy", () => {
renderCard(
root,
{
id: "canonical-takeover-request",
kind: "protocol",
surface: "runtime_request",
runId: "run-1",
requestId: "request-canonical-takeover",
requestKind: "runtime",
turnId: "turn-1",
requestType: "input",
status: "pending",
prompt: "Codex requests user input.",
choices: [],
fields: [],
questionSet: {
schema: "paperclip.question_set.v1",
title: "Codex needs your input",
questions: [
{
id: "environment",
header: "Environment",
prompt: "Where should we deploy?",
required: false,
answerMode: "single_select",
options: [{ id: "staging", label: "Staging" }],
},
],
},
},
vi.fn(),
"takeover",
);
expect(container.textContent).not.toContain("Codex needs your input");
expect(container.textContent).not.toContain("Codex requests user input.");
expect(container.textContent).not.toContain("Choose one");
expect(container.textContent).toContain("Where should we deploy?");
});
it("collapses resolved runtime questions to one expandable timeline row", () => {
renderCard(root, {
id: "resolved-input-request",
kind: "protocol",
surface: "runtime_request",
runId: "run-1",
requestId: "request-resolved-input",
requestKind: "runtime",
turnId: "turn-1",
requestType: "input",
status: "resolved",
prompt: "Codex needs your input.",
choices: [],
fields: [],
resolvedAction: "submit",
questionSet: {
schema: "paperclip.question_set.v1",
title: "Server setup",
questions: [
{
id: "style",
header: "Code style",
prompt: "Which module style should the server use?",
required: true,
answerMode: "single_select",
options: [{ id: "esm", label: "TypeScript ESM" }],
},
],
},
response: {
schema: "paperclip.question_response.v1",
answers: { style: { selectedOptionIds: ["esm"] } },
},
});
const receipt = container.querySelector<HTMLDetailsElement>(
'[data-testid="task-chat-runtime-request"]',
);
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-runtime-request-icon"]')
?.classList.contains("h-3.5"),
).toBe(true);
const caret = receipt?.querySelector(
'[data-testid="task-chat-runtime-request-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.querySelector(
'[data-testid="task-chat-runtime-request-history"]',
),
).not.toBeNull();
expect(container.textContent).toContain(
"Which module style should the server use?",
);
expect(container.textContent).toContain("TypeScript ESM");
expect(container.querySelector("textarea")).toBeNull();
});
it("collapses a cancelled runtime question with its original prompt expandable", () => {
renderCard(root, {
id: "cancelled-input-request",
kind: "protocol",
surface: "runtime_request",
runId: "run-1",
requestId: "request-cancelled-input",
requestKind: "runtime",
turnId: "turn-1",
requestType: "input",
status: "cancelled",
prompt: "Codex needs your input.",
choices: [],
fields: [],
resolvedAction: "cancel",
questionSet: {
schema: "paperclip.question_set.v1",
questions: [
{
id: "goal",
prompt: "What should the server do?",
required: false,
answerMode: "text",
},
],
},
});
const receipt = container.querySelector<HTMLDetailsElement>(
'[data-testid="task-chat-runtime-request"]',
);
expect(receipt?.open).toBe(false);
expect(receipt?.querySelector("summary")?.textContent).toBe(
"Questions cancelled",
);
const iconSlot = receipt?.querySelector(
'[data-testid="task-chat-runtime-request-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);
expect(container.textContent).toContain(
"No answers were submitted; this request was cancelled.",
);
});
it("requires text when an explicit custom multi-select answer is active", async () => {
renderCard(
root,
{
id: "custom-input-request",
kind: "protocol",
surface: "runtime_request",
runId: "run-1",
requestId: "request-custom-input",
requestKind: "runtime",
turnId: "turn-1",
requestType: "input",
status: "pending",
prompt: "Choose regions.",
choices: [],
fields: [],
questionSet: {
schema: "paperclip.question_set.v1",
questions: [
{
id: "regions",
prompt: "Which regions?",
required: true,
answerMode: "multi_select",
options: [{ id: "us", label: "US" }],
customAnswer: { enabled: true, label: "Another region" },
},
{
id: "notes",
prompt: "Notes?",
required: false,
answerMode: "text",
},
],
},
},
vi.fn(),
);
const us = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "US",
);
const custom = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "Another region",
);
await act(async () => us?.click());
await act(async () => custom?.click());
const richInput = container.querySelector(
'[data-testid="question-other-answer-composer"]',
);
expect(richInput).not.toBeNull();
expect(richInput?.classList.contains("border")).toBe(false);
expect(
richInput?.querySelector('[data-image-upload="true"]'),
).not.toBeNull();
const next = Array.from(
container.querySelectorAll<HTMLButtonElement>("button"),
).find((button) => button.getAttribute("aria-label") === "Next question");
expect(next?.disabled).toBe(false);
expect(container.textContent).toContain("Enter a custom answer.");
const textarea = container.querySelector("textarea")!;
await act(async () => {
const setter = Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
"value",
)?.set;
setter?.call(textarea, "ap-south");
textarea.dispatchEvent(new Event("input", { bubbles: true }));
});
expect(next?.disabled).toBe(false);
});
});