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>
This commit is contained in:
Dotta 2026-09-11 11:59:24 -05:00 committed by GitHub
parent eb640ec129
commit 42a4f5b15b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 340 additions and 23 deletions

View File

@ -82,6 +82,12 @@ pnpm build-storybook
These run the `@paperclipai/ui` Storybook on port `6006` and build the static output to `ui/storybook-static/`.
Use **Chat & Comments → Issue Thread Interactions → Composer Questions Auto Advance**
to try the paged composer form. A single selection shows a brief checked-state animation before advancing to the
next question. Reduced-motion mode advances without animation.
Multi-select and custom answers wait for Next, and the final page waits for
Submit answers. The adjacent **Verified** story exercises the full flow.
The Storybook visual regression suite uses external PNG baselines instead of
committed screenshots:

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
Check,
ChevronLeft,
@ -24,6 +24,7 @@ import {
useTaskChatComposerTakeoverActions,
} from "./TaskChatComposerTakeoverContext";
import { TaskChatRichInput } from "./TaskChatRichInput";
import { parseCssTimeMs } from "./motion-tokens";
import { matchSafeQuestionValidationPattern } from "./question-validation-pattern";
type Question = PaperclipQuestionSet["questions"][number];
@ -108,6 +109,7 @@ function SelectOption({
recommended,
selected,
multiple,
confirming = false,
disabled,
onClick,
}: {
@ -117,6 +119,7 @@ function SelectOption({
recommended?: boolean;
selected: boolean;
multiple: boolean;
confirming?: boolean;
disabled: boolean;
onClick: () => void;
}) {
@ -128,9 +131,9 @@ function SelectOption({
aria-checked={selected}
disabled={disabled}
className={cn(
"flex w-full items-start gap-2 rounded-md px-2.5 py-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-60",
"tc-question-option flex w-full items-start gap-2 rounded-md px-2.5 py-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-60",
selected
? "bg-muted/80"
? "bg-foreground/5"
: recommended
? "bg-muted/50"
: "hover:bg-muted/40",
@ -144,6 +147,7 @@ function SelectOption({
className={cn(
"mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center border",
multiple ? "rounded-sm" : "rounded-full",
confirming && "tc-question-choice-confirm",
selected
? "border-primary bg-primary text-primary-foreground"
: "border-muted-foreground/50",
@ -264,6 +268,21 @@ export function QuestionForm({
const [working, setWorking] = useState<"submit" | "cancel" | null>(null);
const [inputUploading, setInputUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pendingAdvance, setPendingAdvance] = useState<{
page: number;
questionId: string;
optionId: string;
} | null>(null);
const promptRef = useRef<HTMLParagraphElement>(null);
const previousPage = useRef(page);
useEffect(() => {
if (previousPage.current !== page) {
promptRef.current?.focus();
previousPage.current = page;
}
}, [page]);
const [filters, setFilters] = useState<Record<string, string>>({});
useEffect(() => {
@ -277,6 +296,38 @@ export function QuestionForm({
}, [answers, customActive, draftKey, page]);
const question = questionSet.questions[page];
useEffect(() => {
if (!pendingAdvance) return;
if (
disabled ||
working ||
inputUploading ||
pendingAdvance.page !== page ||
pendingAdvance.questionId !== question?.id ||
page >= questionSet.questions.length - 1
) {
setPendingAdvance(null);
return;
}
const duration = getComputedStyle(document.documentElement)
.getPropertyValue("--motion-question-confirm")
.trim();
const durationMs = parseCssTimeMs(duration) || 0;
const advance = () => {
setPendingAdvance(null);
setPage(page + 1);
};
// With reduced motion (or without CSS), no visual hold is needed.
if (durationMs <= 0) {
advance();
return;
}
const timer = window.setTimeout(advance, durationMs);
return () => window.clearTimeout(timer);
}, [
pendingAdvance, page, question?.id, questionSet.questions.length,
disabled, working, inputUploading,
]);
const validationErrors = useMemo(
() =>
Object.fromEntries(
@ -316,6 +367,7 @@ export function QuestionForm({
}
function toggleOption(optionId: string) {
if (disabled || working || inputUploading) return;
const optionIds = multiple
? selected.includes(optionId)
? selected.filter((candidate) => candidate !== optionId)
@ -326,14 +378,20 @@ export function QuestionForm({
selectedOptionIds: optionIds,
...(!multiple ? { customText: undefined } : {}),
};
// Picking only selects. Next / Submit answers moves on or sends, so a
// click can never start work by itself.
setAnswers({ ...answers, [question.id]: nextAnswer });
if (!multiple)
if (!multiple) {
setCustomActive((current) => ({ ...current, [question.id]: false }));
// Briefly confirm the selected choice before moving on. The last page needs an
// explicit submit, and custom answers stay open for typing.
if (page < questionSet.questions.length - 1) {
setError(null);
setPendingAdvance({ page, questionId: question.id, optionId });
}
}
}
function toggleCustom() {
setPendingAdvance(null);
const active = !isCustomActive;
setCustomActive((current) => ({ ...current, [question.id]: active }));
updateAnswer({
@ -461,10 +519,13 @@ export function QuestionForm({
}
return (
<div
key={question.id}
className="tc-question-page"
onKeyDown={(event) => {
if (
disabled ||
working ||
event.repeat ||
question.answerMode === "text" ||
event.metaKey ||
event.ctrlKey ||
@ -508,6 +569,8 @@ export function QuestionForm({
</p>
) : null}
<p
ref={promptRef}
tabIndex={-1}
id={`${id}-${question.id}-prompt`}
className="text-sm font-medium leading-5 text-foreground"
>
@ -572,6 +635,7 @@ export function QuestionForm({
description={option.description}
recommended={option.recommended}
selected={selected.includes(option.id)}
confirming={pendingAdvance?.questionId === question.id && pendingAdvance.optionId === option.id}
multiple={multiple}
disabled={disabled || working != null}
onClick={() => toggleOption(option.id)}

View File

@ -2058,7 +2058,7 @@ describe("TaskChatComposer", () => {
});
});
it("moves through questions with Next, Skip leaves one unanswered, Submit answers sends", async () => {
it("advances single selections, preserves answers when going back, and skips optional answers", async () => {
const onSubmit = vi.fn();
render(
<TaskChatComposer
@ -2118,14 +2118,16 @@ describe("TaskChatComposer", () => {
flushSync(() => byLabel("Staging")?.click());
await flushAsync();
expect(onSubmit).not.toHaveBeenCalled();
expect(byLabel("Next")?.disabled).toBe(false);
flushSync(() => byLabel("Next")?.click());
await flushAsync();
expect(container.textContent).toContain("When?");
expect(document.activeElement?.textContent).toBe("When?");
// Page 2: optional. Pick, then Skip anyway — the pick is dropped.
// Page 2: pick advances. Go back to confirm it is saved, then skip.
flushSync(() => byLabel("Today")?.click());
await flushAsync();
expect(container.textContent).toContain("Who?");
flushSync(() => container.querySelector<HTMLButtonElement>('button[aria-label="Previous question"]')?.click());
await flushAsync();
expect(byLabel("Today")?.getAttribute("aria-checked")).toBe("true");
flushSync(() => byLabel("Skip")?.click());
await flushAsync();
expect(container.textContent).toContain("Who?");
@ -2144,6 +2146,126 @@ describe("TaskChatComposer", () => {
expect(response.answers.who).toEqual({ selectedOptionIds: ["me"] });
});
it.each(["click", "keyboard"])("advances a single choice by %s, while Other and multi-select stay put", async (input) => {
const onSubmit = vi.fn();
render(<QuestionForm
id="selection-modes"
questionSet={{
schema: "paperclip.question_set.v1",
questions: [
{ id: "storage", prompt: "Storage?", required: true, answerMode: "single_select",
options: [{ id: "sqlite", label: "SQLite" }], customAnswer: { enabled: true } },
{ id: "features", prompt: "Features?", required: true, answerMode: "multi_select",
options: [{ id: "auth", label: "Sign in" }, { id: "search", label: "Search" }] },
{ id: "notes", prompt: "Notes?", required: false, answerMode: "text" },
],
}}
initialResponse={{ schema: "paperclip.question_response.v1", answers: { storage: { selectedOptionIds: ["sqlite"] } } }}
onSubmit={onSubmit}
/>);
const byLabel = (label: string) => Array.from(container.querySelectorAll<HTMLButtonElement>("button"))
.find((button) => button.textContent?.trim() === label)!;
// Restoring a selection does not advance. Other needs text first.
expect(container.textContent).toContain("1 of 3");
flushSync(() => byLabel("Other").click());
await flushAsync();
expect(container.textContent).toContain("1 of 3");
expect(container.querySelector('[data-testid="question-other-answer-composer"]')).not.toBeNull();
expect(byLabel("Next").disabled).toBe(true);
flushSync(() => {
if (input === "click") byLabel("SQLite").click();
else byLabel("SQLite").dispatchEvent(new KeyboardEvent("keydown", { key: "1", bubbles: true }));
});
await flushAsync();
expect(container.textContent).toContain("2 of 3");
expect(document.activeElement?.textContent).toBe("Features?");
flushSync(() => document.activeElement?.dispatchEvent(new KeyboardEvent("keydown", { key: "1", repeat: true, bubbles: true })));
expect(byLabel("Sign in").getAttribute("aria-checked")).toBe("false");
flushSync(() => byLabel("Sign in").click());
flushSync(() => byLabel("Search").click());
expect(container.textContent).toContain("2 of 3");
expect(byLabel("Sign in").getAttribute("aria-checked")).toBe("true");
expect(byLabel("Search").getAttribute("aria-checked")).toBe("true");
expect(onSubmit).not.toHaveBeenCalled();
flushSync(() => byLabel("Next").click());
await flushAsync();
expect(container.textContent).toContain("3 of 3");
flushSync(() => byLabel("Submit answers").click());
await flushAsync();
expect(onSubmit).toHaveBeenCalledExactlyOnceWith({
schema: "paperclip.question_response.v1",
answers: { storage: { selectedOptionIds: ["sqlite"] }, features: { selectedOptionIds: ["auth", "search"] } },
});
});
describe("single-choice confirmation animation", () => {
const questionSet = {
schema: "paperclip.question_set.v1" as const,
questions: ["First", "Second", "Third"].map((prompt) => ({
id: prompt, prompt, required: true, answerMode: "single_select" as const,
options: [{ id: "yes", label: "Yes" }], customAnswer: { enabled: true as const },
})),
};
const form = (disabled = false) => (
<QuestionForm id="animated" questionSet={questionSet} disabled={disabled} onSubmit={vi.fn()} />
);
const click = (selector: string) => act(() => {
flushSync(() => container.querySelector<HTMLButtonElement>(selector)!.click());
});
beforeEach(() => {
vi.useFakeTimers();
document.documentElement.style.setProperty("--motion-question-confirm", "160ms");
});
afterEach(() => {
render(<div />);
vi.useRealTimers();
document.documentElement.style.removeProperty("--motion-question-confirm");
});
it("shows the selected radio before advancing exactly one page", () => {
render(form());
click('[role="radio"]');
expect(container.querySelector('[role="radio"]')?.getAttribute("aria-checked")).toBe("true");
expect(container.querySelector(".tc-question-choice-confirm")).not.toBeNull();
expect(container.textContent).toContain("1 of 3");
act(() => vi.advanceTimersByTime(159));
expect(container.textContent).toContain("1 of 3");
act(() => vi.advanceTimersByTime(1));
expect(container.textContent).toContain("2 of 3");
expect(document.activeElement?.textContent).toBe("Second");
act(() => vi.advanceTimersByTime(160));
expect(container.textContent).toContain("2 of 3");
});
it.each(["navigation", "custom answer", "disabled", "unmount"])("cancels the pending advance on %s", (reason) => {
render(form());
click('[role="radio"]');
if (reason === "navigation") {
click('[aria-label="Next question"]');
click('[aria-label="Next question"]');
} else if (reason === "custom answer") {
click('#animated-First-custom');
} else if (reason === "disabled") {
render(form(true));
render(form(false));
} else {
render(<div>Closed</div>);
}
act(() => vi.advanceTimersByTime(160));
expect(container.textContent).toContain(
reason === "navigation" ? "3 of 3" : reason === "unmount" ? "Closed" : "1 of 3",
);
});
it("advances immediately when the motion token is zero", () => {
document.documentElement.style.setProperty("--motion-question-confirm", "0ms");
render(form());
click('[role="radio"]');
expect(container.textContent).toContain("2 of 3");
expect(container.querySelector(".tc-question-choice-confirm")).toBeNull();
});
});
it("Skip on the last question submits the other answers", async () => {
const onSubmit = vi.fn();
render(
@ -2190,7 +2312,6 @@ describe("TaskChatComposer", () => {
container.querySelectorAll<HTMLButtonElement>("button"),
).find((button) => button.textContent?.trim() === label);
flushSync(() => byLabel("Staging")?.click());
flushSync(() => byLabel("Next")?.click());
await flushAsync();
expect(container.textContent).toContain("Anything else?");
flushSync(() => byLabel("Skip")?.click());
@ -2389,7 +2510,7 @@ describe("TaskChatComposer", () => {
flushSync(() => staging?.click());
expect(staging?.getAttribute("data-selected")).toBe("true");
expect(staging?.className.split(" ")).toContain("bg-muted/80");
expect(staging?.className.split(" ")).toContain("bg-foreground/5");
});
it("hides Skip when the takeover already provides a request-changes path", () => {

View File

@ -437,12 +437,7 @@ describe("TaskChatInteractionCard", () => {
button.textContent?.includes("Only collapse hidden descendants"),
);
await act(async () => firstAnswer?.click());
// Picking only selects; Next moves to the second question.
expect(container.textContent).toContain("1 of 2");
const next = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "Next",
);
await act(async () => next?.click());
expect(submit).not.toHaveBeenCalled();
expect(container.textContent).toContain("2 of 2");
expect(container.textContent).toContain(

View File

@ -604,14 +604,13 @@ describe("TaskChatProtocolCard", () => {
(button) => button.textContent?.includes("Production"),
);
await act(async () => production?.click());
// Picking only selects; the primary button reads Next until the last
// question, where it takes the set's submit label.
// 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("Where should we deploy?");
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?",
);

View File

@ -55,6 +55,8 @@ export const MOTION_TOKENS: MotionTokenDef[] = [
{ name: "--motion-approval-pulse", group: "States", kind: "time", min: 0, max: 3000, step: 20 },
{ name: "--motion-plan-entry-stagger", group: "States", kind: "time", min: 0, max: 300, step: 5 },
{ name: "--motion-plan-check", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
{ name: "--motion-question-confirm", group: "States", kind: "time", min: 0, max: 1000, step: 10 },
{ name: "--motion-question-page-enter", group: "States", kind: "time", min: 0, max: 1000, step: 10 },
{ name: "--motion-count-tween", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
{ name: "--motion-streaming-cursor-blink", group: "States", kind: "time", min: 0, max: 3000, step: 20 },
{ name: "--motion-turn-fold", group: "States", kind: "time", min: 0, max: 1500, step: 10 },

View File

@ -260,6 +260,8 @@
--motion-approval-pulse: 1.6s;
--motion-plan-entry-stagger: 40ms;
--motion-plan-check: var(--motion-duration-fast);
--motion-question-confirm: var(--motion-duration-fast);
--motion-question-page-enter: var(--motion-duration-instant);
--motion-count-tween: var(--motion-duration-slow);
--motion-streaming-cursor-blink: 1.1s;
--motion-turn-fold: 380ms; /* finished-turn activity folding into its summary line */
@ -857,6 +859,14 @@
0%, 100% { box-shadow: 0 0 0 0 color-mix(in oklab, var(--primary) 0%, transparent); }
50% { box-shadow: 0 0 0 3px color-mix(in oklab, var(--primary) 18%, transparent); }
}
@keyframes tc-question-choice-confirm {
from { transform: scale(0.65); }
50%, to { transform: scale(1); }
}
.tc-question-choice-confirm { animation: tc-question-choice-confirm var(--motion-question-confirm) var(--motion-ease-out-expo) both; }
.tc-question-option { transition-duration: var(--motion-duration-instant); }
.tc-question-page { animation: tc-fade-in var(--motion-question-page-enter) var(--motion-ease-standard) both; }
@keyframes tc-cursor-blink {
0%, 45% { opacity: 1; }
55%, 100% { opacity: 0; }

View File

@ -1,4 +1,9 @@
import { useEffect, useRef, useState } from "react";
import { expect, userEvent, waitFor, within } from "storybook/test";
import type { PaperclipQuestionResponse, PaperclipQuestionSet } from "@paperclipai/adapter-utils";
import { QuestionForm, QuestionResponseSummary } from "@/components/task-chat/QuestionForm";
import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer";
import { Button } from "@/components/ui/button";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { IssueChatThread } from "@/components/IssueChatThread";
import { IssueThreadInteractionCard } from "@/components/IssueThreadInteractionCard";
@ -496,6 +501,121 @@ export const SuggestedTasksRejected: Story = {
),
};
const composerQuestions: PaperclipQuestionSet = {
schema: "paperclip.question_set.v1",
title: "Express app — scope",
questions: [
{
id: "storage",
prompt: "Does it need to store anything?",
answerMode: "single_select",
required: true,
options: [
{ id: "memory", label: "No — in-memory is fine", description: "Fastest to something running; state dies on restart." },
{ id: "sqlite", label: "SQLite file", description: "Real persistence, zero infrastructure. Good default for a first version." },
{ id: "postgres", label: "Postgres", description: "Needs a database to point at." },
],
customAnswer: { enabled: true },
},
{
id: "features",
prompt: "Which features should it include?",
helpText: "Choose all that apply, then click Next.",
answerMode: "multi_select",
required: true,
options: [
{ id: "auth", label: "Sign in" },
{ id: "search", label: "Search" },
{ id: "uploads", label: "File uploads" },
],
},
{
id: "timing",
prompt: "When should we start?",
answerMode: "single_select",
required: true,
options: [
{ id: "now", label: "Now" },
{ id: "later", label: "Later" },
],
},
],
};
function InteractiveComposerQuestions() {
const [response, setResponse] = useState<PaperclipQuestionResponse | null>(null);
const [open, setOpen] = useState(true);
const [reset, setReset] = useState(0);
return (
<div className="mx-auto max-w-2xl space-y-4">
{response ? (
<div role="status" className="space-y-3">
<p className="text-sm font-medium">Answers submitted</p>
<QuestionResponseSummary questionSet={composerQuestions} response={response} />
</div>
) : null}
<TaskChatComposer
onAdd={() => {}}
workMode="standard"
takeover={open ? {
id: `composer-questions-${reset}`,
label: composerQuestions.title!,
pendingCount: 1,
inlineSkip: true,
content: <QuestionForm
key={reset}
id="composer-questions"
questionSet={composerQuestions}
onSubmit={(next) => { setResponse(next); setOpen(false); }}
/>,
onDismiss: () => setOpen(false),
onSkip: () => setOpen(false),
} : null}
/>
<Button variant="outline" onClick={() => { setResponse(null); setReset((value) => value + 1); setOpen(true); }}>
Restart questions
</Button>
</div>
);
}
/** Single choices advance, while Other, multi-select, and final submission wait. */
export const ComposerQuestionsAutoAdvance: Story = {
render: () => (
<StoryFrame>
<ScenarioCard
title="Composer questions"
description="Selected rows are lightly highlighted. Choose a single option to see a quick selection confirmation, then advance. Other stays open for typing. Multi-select waits for Next, and the last question waits for Submit answers."
>
<InteractiveComposerQuestions />
</ScenarioCard>
</StoryFrame>
),
};
export const ComposerQuestionsAutoAdvanceVerified: Story = {
...ComposerQuestionsAutoAdvance,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("radio", { name: "Other" }));
await expect(canvas.getByText("1 of 3")).toBeVisible();
await expect(canvas.getByTestId("question-other-answer-composer")).toBeVisible();
await userEvent.click(canvas.getByRole("radio", { name: /SQLite file/ }));
await waitFor(() => expect(canvas.getByText("2 of 3")).toBeVisible());
await userEvent.click(canvas.getByRole("checkbox", { name: "Sign in" }));
await userEvent.click(canvas.getByRole("checkbox", { name: "Search" }));
await expect(canvas.getByText("2 of 3")).toBeVisible();
await userEvent.click(canvas.getByRole("button", { name: "Next" }));
await userEvent.click(canvas.getByRole("radio", { name: "Now" }));
await expect(canvas.getByText("3 of 3")).toBeVisible();
await expect(canvas.queryByText("Answers submitted")).not.toBeInTheDocument();
await userEvent.click(canvas.getByRole("button", { name: "Submit answers" }));
await expect(canvas.getByText("Answers submitted")).toBeVisible();
await expect(canvas.getByText("SQLite file", { exact: true })).toBeVisible();
await expect(canvas.getByText("Sign in, Search", { exact: true })).toBeVisible();
},
};
export const AskUserQuestionsPending: Story = {
render: () => (
<StoryFrame>