mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-13 23:39:21 +02:00
## 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>
875 lines
29 KiB
TypeScript
875 lines
29 KiB
TypeScript
// @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);
|
||
});
|
||
});
|