diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 1cf4ccbce5..e145ac21fa 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -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: diff --git a/ui/src/components/task-chat/QuestionForm.tsx b/ui/src/components/task-chat/QuestionForm.tsx index bce143bcdb..2ed848b21c 100644 --- a/ui/src/components/task-chat/QuestionForm.tsx +++ b/ui/src/components/task-chat/QuestionForm.tsx @@ -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(null); + const [pendingAdvance, setPendingAdvance] = useState<{ + page: number; + questionId: string; + optionId: string; + } | null>(null); + const promptRef = useRef(null); + const previousPage = useRef(page); + + useEffect(() => { + if (previousPage.current !== page) { + promptRef.current?.focus(); + previousPage.current = page; + } + }, [page]); + const [filters, setFilters] = useState>({}); 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 (
{ if ( disabled || working || + event.repeat || question.answerMode === "text" || event.metaKey || event.ctrlKey || @@ -508,6 +569,8 @@ export function QuestionForm({

) : null}

@@ -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)} diff --git a/ui/src/components/task-chat/TaskChatComposer.test.tsx b/ui/src/components/task-chat/TaskChatComposer.test.tsx index 1de546780b..50fa3d275c 100644 --- a/ui/src/components/task-chat/TaskChatComposer.test.tsx +++ b/ui/src/components/task-chat/TaskChatComposer.test.tsx @@ -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( { 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('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(); + const byLabel = (label: string) => Array.from(container.querySelectorAll("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) => ( + + ); + const click = (selector: string) => act(() => { + flushSync(() => container.querySelector(selector)!.click()); + }); + beforeEach(() => { + vi.useFakeTimers(); + document.documentElement.style.setProperty("--motion-question-confirm", "160ms"); + }); + afterEach(() => { + render(

); + 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(
Closed
); + } + 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("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", () => { diff --git a/ui/src/components/task-chat/TaskChatInteractionCard.test.tsx b/ui/src/components/task-chat/TaskChatInteractionCard.test.tsx index e7e3427f01..07b7034e81 100644 --- a/ui/src/components/task-chat/TaskChatInteractionCard.test.tsx +++ b/ui/src/components/task-chat/TaskChatInteractionCard.test.tsx @@ -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( diff --git a/ui/src/components/task-chat/TaskChatProtocolCard.test.tsx b/ui/src/components/task-chat/TaskChatProtocolCard.test.tsx index e1498c40dc..b268f09e47 100644 --- a/ui/src/components/task-chat/TaskChatProtocolCard.test.tsx +++ b/ui/src/components/task-chat/TaskChatProtocolCard.test.tsx @@ -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("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?", ); diff --git a/ui/src/components/task-chat/motion-tokens.ts b/ui/src/components/task-chat/motion-tokens.ts index 98b864e7f7..6e6074441c 100644 --- a/ui/src/components/task-chat/motion-tokens.ts +++ b/ui/src/components/task-chat/motion-tokens.ts @@ -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 }, diff --git a/ui/src/index.css b/ui/src/index.css index 3fa64217f7..30c4328a2b 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -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; } diff --git a/ui/storybook/stories/issue-thread-interactions.stories.tsx b/ui/storybook/stories/issue-thread-interactions.stories.tsx index 371082cbda..a4bd74796b 100644 --- a/ui/storybook/stories/issue-thread-interactions.stories.tsx +++ b/ui/storybook/stories/issue-thread-interactions.stories.tsx @@ -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(null); + const [open, setOpen] = useState(true); + const [reset, setReset] = useState(0); + return ( +
+ {response ? ( +
+

Answers submitted

+ +
+ ) : null} + {}} + workMode="standard" + takeover={open ? { + id: `composer-questions-${reset}`, + label: composerQuestions.title!, + pendingCount: 1, + inlineSkip: true, + content: { setResponse(next); setOpen(false); }} + />, + onDismiss: () => setOpen(false), + onSkip: () => setOpen(false), + } : null} + /> + +
+ ); +} + +/** Single choices advance, while Other, multi-select, and final submission wait. */ +export const ComposerQuestionsAutoAdvance: Story = { + render: () => ( + + + + + + ), +}; + +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: () => (