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:
parent
eb640ec129
commit
42a4f5b15b
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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?",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue