Files
PaperClipAI/ui/src/components/task-chat/TaskChatProtocolCard.test.tsx
T
scotttongandClaude Opus 5 34355e5109 fix(task-chat): selecting an option no longer jumps to the next question (#13602)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - An agent that needs a decision asks in the task composer, which
renders a question set one question per page
> - Single-choice questions use radio options, and the composer also
ships a "Next" button and pagination arrows
> - Selecting a radio option also set a pending advance, waited for a
short confirm animation, and turned the page on its own
> - That takes the page away from the reader while they are still
reading it, and a misclick costs them the question
> - Nothing on the option says that clicking it will navigate
> - This pull request makes selection answer the question and nothing
else
> - The benefit is that moving between questions is always something the
reader chose to do

## Linked Issues or Issue Description

**What happened?**

In the task composer, selecting an option with a radio button jumps to
the next question. `QuestionForm.toggleOption()` set `pendingAdvance`
for any single-select question that was not on the last page, and an
effect then read `--motion-question-confirm`, waited that long, and
called `setPage(page + 1)`. With reduced motion it advanced immediately,
with no pause at all.

The result is that a click meant to answer a question also navigates
away from it. There is no way to read the rest of the page after
choosing, and no way to undo the jump other than pressing the back
arrow.

**Expected behavior**

Selecting an option records the answer and stays on the question. Moving
to the next question stays an explicit act.

**Steps to reproduce**

1. Get an agent to ask a question set with two or more single-choice
questions.
2. Open the question in the task composer.
3. Click one of the radio options.
4. Before this change the composer moves to the next question on its
own.

**Paperclip version or commit**

`45586170e` on `master`.

**Deployment mode**

Local.

**Agent adapter(s) involved**

Not adapter-specific (core bug).

**Additional context**

Every route forward already shipped beside the options, so removing the
shortcut traps no one:

- a footer button that reads "Next" on any page but the last, and the
submit label on the last;
- previous and next pagination arrows;
- "Skip" on questions that are not required.

The same question sets render outside the composer in
`IssueThreadInteractionCard`, which never had the auto-advance. This
change brings the two surfaces to the same behavior.

## What Changed

- `QuestionForm` no longer sets a pending advance when a single-choice
option is selected. The effect that turned the page is removed with it.
- Selecting an option still clears a submit error about a missing
answer, which is the case that error is about.
- The confirm-before-advance animation existed only to soften the jump.
Its `--motion-question-confirm` token, its keyframes, its class, and the
now-unused `confirming` prop on the option button are removed.
- Tests that asserted the jump now assert the opposite: selection leaves
the page number unchanged, "Next" advances, and number-key selection
stays on the same question.

## Verification

- `npx vitest run ui/src/components/task-chat` — the composer,
interaction card, protocol card, and motion token suites pass.
- `npx vitest run ui/src/components/task-chat/TaskChatComposer.test.tsx`
— 87 tests pass, including four new tests for the stationary behavior.
- `npx tsc -b ui` — clean.
- Keyboard: options keep `role="radio"` inside a `radiogroup`, and a
test asserts that number-key selection selects without navigating.

## Risks

Low. One deliberate behavior is removed. It costs a reader one extra
click per question on multi-question sets, and the explicit control for
that click already existed and is already tested.

## Model Used

Claude Opus 5 (`claude-opus-5`), 1M context, extended thinking, with
tool use and code execution.

## 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
- [ ] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 14:18:39 -07:00

913 lines
30 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.each(["text/html", "application/zip"])("labels %s artifact links as downloads", (contentType) => {
const contentPath = "/api/attachments/file/content";
flushSync(() => root.render(
<RichWorkProductCard
workProduct={workProduct({ type: "artifact", metadata: { contentType, contentPath } })}
href={contentPath}
/>,
));
expect(container.textContent).toContain("Download");
expect(container.textContent).not.toContain("Open preview");
expect(container.querySelector("a")?.getAttribute("href")).toBe(`${contentPath}?download=1`);
});
it.each(["application/pdf", "text/plain", "text/markdown", "application/json", "text/csv"])("downloads %s artifacts through the download route", (contentType) => {
const contentPath = "/api/attachments/file/content";
const downloadPath = `${contentPath}?download=1`;
flushSync(() => root.render(
<RichWorkProductCard
workProduct={workProduct({ type: "artifact", metadata: { contentType, contentPath, openPath: contentPath, downloadPath } })}
href={contentPath}
/>,
));
expect(container.querySelector('a[aria-label^="Download:"]')?.getAttribute("href")).toBe(downloadPath);
});
it("downloads a legacy attachment with only a content href", () => {
const contentPath = "/api/attachments/legacy-file/content";
flushSync(() => root.render(
<RichWorkProductCard
workProduct={workProduct({ type: "artifact", metadata: { contentType: "application/pdf" } })}
href={contentPath}
/>,
));
expect(container.querySelector('a[aria-label^="Download:"]')?.getAttribute("href")).toBe(`${contentPath}?download=1`);
});
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());
// Selecting answers the question; every page waits for Next.
const nextButton = () =>
Array.from(container.querySelectorAll<HTMLButtonElement>("button")).find(
(button) => button.textContent?.trim() === "Next",
);
expect(container.textContent).toContain("1 of 3");
await act(async () => nextButton()?.click());
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);
});
});