From c740cb9dede725bdc6ff810f859674256ba8e8b2 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sat, 7 Nov 2026 08:34:14 -0600 Subject: [PATCH] fix(ui): dismiss skipped confirmations when sending a message (#19496) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Tasks and Agent Chat show saved requests above the message composer. > - A person can send a separate message while a confirmation is open. > - The old code closed only question forms, after the message save finished. > - A confirmation could stay open or open again after a refresh. > - This change closes the form when sending starts and keeps the request in the feed. > - People can open the request later and make the original decision. ## Linked Issues or Issue Description **What happened?** A confirmation stayed open when a person sent a separate message. **Expected behavior** The form closes immediately. The request stays in the feed. A person can open it later. Sending a message does not record an approval. **Steps to reproduce** 1. Open a task with a pending confirmation. 2. Send a separate message below the form. 3. Observe the form while the message save is pending. 4. Reload the task and open the request from the feed. **Paperclip version or commit** Reproduced at a434edf991f22760973d3f84df343a10a0e685db. **Deployment mode** Source checkout and isolated Storybook fixtures. ## What Changed - Close the composer request before the message save finishes. - Keep earlier saved requests in history after a newer human message. - Save browser dismissal IDs in one batch after a successful send. - Restore the previous form after a failed send, including a reopened request after an optimistic comment changes automatic selection. - Track deliberate selection changes separately from automatic filtering. Keep a newer request open when an earlier send fails. - Allow a new request to open while an earlier message is being saved. - Add tests for confirmation and checkbox forms in task and Agent Chat modes. - Add Send Instead Of Confirming and Reopen Skipped Confirmation stories. - Update the existing answer-later story to check the current answer receipt. - Update the behavior and Storybook documentation. ## Verification - TDD: Six new cases failed before the main fix. Four more cases reproduced the review finding before the recovery fix. They cover failed sends with an optimistic comment and its rollback in both task and Agent Chat modes. - Component checks: 518 passed, one existing test skipped. Run `pnpm --filter @paperclipai/ui exec vitest run src/components/TaskChatThread.test.tsx src/components/task-chat/TaskChatComposer.test.tsx`. - UI typecheck passed after preparing the local plugin SDK dependency. - Token gates passed. - Catalog structure checks passed. The three catalog test files passed all 30 tests. - Six Playwright cases passed in an owned Storybook server. They cover the initial form, sending, manual reopening, light theme, mobile, and answering later. Play assertions passed. Accessibility reports had zero violations. - The stories use the production thread with simulated saves and decisions. Existing optional fixture endpoints return 404. These do not prevent the selected journeys. - No visual baseline comparison was possible. The baseline manifest has no published snapshots. - `pnpm verify --explain` requested broader CI coverage. [Required CI](https://github.com/paperclipai/paperclip/actions/runs/42253585800) passed on fda44f2648902807834f7d6d729e90c53726b208. - Greptile and Paperclip Review both report 5/5 with no findings on the final head. - The remote commit tree matches the tested local tree: 7e22201513bae1e0de7e053e1b48b3d0f7f052e2. ## Risks - Older pending requests now stay in history after a newer human message. People must select the feed entry to open them. - Dismissal changes presentation. Server approval checks and request decisions keep their existing behavior. - Browser fixtures do not prove live provider continuation or staging behavior. ## Model Used OpenAI GPT-6 Codex. The exact backend model identifier and context window were not exposed to this run. Used reasoning, repository inspection, edits, shell tools, component tests, and browser tools. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [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 --- doc/DEVELOPING.md | 4 + doc/SPEC-implementation.md | 6 + ui/src/components/TaskChatThread.test.tsx | 117 ++++++++++++++++++ ui/src/components/TaskChatThread.tsx | 55 ++++---- ui/src/hooks/useDismissedTaskQuestions.ts | 4 +- .../fixtures/unansweredQuestionChat.tsx | 2 +- ...task-chat-unanswered-questions.stories.tsx | 35 +++++- 7 files changed, 198 insertions(+), 25 deletions(-) diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 9d23e4d847..502872b7ce 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -648,6 +648,10 @@ behavior for review cards. Its dismissal save and decisions are simulated; server persistence and permission boundaries have separate regression tests. The demo uses real thread/form components with fixture response callbacks. The Agent Chat stories share the same component fixture. +The **Send instead of confirming** and **Reopen skipped confirmation** stories +exercise sending a separate message while a completion review is open, then +opening that unchanged request from the feed. These use the production thread +with simulated message saves and decision callbacks. Use **Composer → New task** to review task creation through the production `TaskChatComposer`. The editor, file menu, work modes, assignee/model picker, diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 82fa68c2de..803ebd93e3 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -2023,6 +2023,12 @@ Dismissal never creates an answer, decision, execution, or continuation wake. Raw runtime requests retain browser-local dismissal; reopening restores the original request and draft. A failed save leaves the request open with an error. +Sending a separate message closes the current composer request immediately, +before the send finishes. After a successful send, earlier requests stay in the +feed and can be reopened manually; a newer request can still open normally. +If sending fails, restore the previous form unless the person has opened a +different request. This presentation change does not answer or approve a request. + A saved ordinary question becomes historical after a newer human task message or explicit human dismissal. It remains answerable after completion without reopening work or waking the agent. Dismissed ordinary requests, including native diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx index 0f192558fa..ae9a99cd87 100644 --- a/ui/src/components/TaskChatThread.test.tsx +++ b/ui/src/components/TaskChatThread.test.tsx @@ -4036,6 +4036,123 @@ describe.each([true, false])("Unanswered question history (conversationMode=%s)" expect(container.querySelector('[data-testid="task-chat-unanswered-question"]')).not.toBeNull(); }); + it.each(["request_confirmation", "request_checkbox_confirmation"] as const)("immediately closes %s while a new message is sending and keeps it reopenable", async kind => { + const fixtures = await import("@/fixtures/issueThreadInteractionFixtures"); + const request = kind === "request_confirmation" + ? fixtures.genericPendingRequestConfirmationInteraction + : fixtures.pendingRequestCheckboxConfirmationInteraction; + let finishSend!: () => void; + const onAdd = vi.fn(() => new Promise(resolve => { finishSend = resolve; })); + const onAcceptInteraction = vi.fn(); + const onRejectInteraction = vi.fn(); + const show = () => render(); + show(); + expect(takeover()).not.toBeNull(); + await act(async () => editorCallbacks.change("Let's discuss something else first.")); + await act(async () => container.querySelector('[data-testid="task-chat-composer-send"]')!.click()); + expect(onAdd).toHaveBeenCalledTimes(1); + expect(takeover()).toBeNull(); + await act(async () => finishSend()); + expect(pendingIndicator()).toBeNull(); + flushSync(() => root!.unmount()); + root = createRoot(container); + show(); + expect(takeover()).toBeNull(); + await act(async () => container.querySelector(`[aria-label="Open request: ${request.title}"]`)!.click()); + expect(takeover()).not.toBeNull(); + expect(request.status).toBe("pending"); + expect(onAcceptInteraction).not.toHaveBeenCalled(); + expect(onRejectInteraction).not.toHaveBeenCalled(); + await act(async () => editorCallbacks.change("Keep the review for later.")); + await act(async () => container.querySelector('[data-testid="task-chat-composer-send"]')!.click()); + expect(takeover()).toBeNull(); + await act(async () => finishSend()); + expect(takeover()).toBeNull(); + }); + + it("leaves an earlier confirmation in history after a newer user message, including on reload", async () => { + const request = { ...genericPendingRequestConfirmationInteraction, createdAt: old.createdAt }; + const show = (comments = movedOn.slice(0, 0)) => render(); + show(); + expect(takeover()).not.toBeNull(); + await act(async () => show(movedOn)); + expect(takeover()).toBeNull(); + expect(pendingIndicator()).toBeNull(); + flushSync(() => root!.unmount()); + root = createRoot(container); + show(movedOn); + expect(takeover()).toBeNull(); + await act(async () => container.querySelector(`[aria-label="Open request: ${request.title}"]`)!.click()); + expect(takeover()).not.toBeNull(); + }); + + it.each([true, false])("keeps a new confirmation open when an earlier message finishes sending (success=%s)", async success => { + const request = genericPendingRequestConfirmationInteraction; + const next = { ...request, id: "new-review", title: "New review", createdAt: new Date("2026-11-06T12:00:00Z") }; + let finishSend!: () => void; + let rejectSend!: (error: Error) => void; + const onAdd = vi.fn(() => new Promise((resolve, reject) => { finishSend = resolve; rejectSend = reject; })); + const show = (interactions = [request]) => render(); + show(); + await act(async () => editorCallbacks.change("Let's discuss something else first.")); + await act(async () => container.querySelector('[data-testid="task-chat-composer-send"]')!.click()); + expect(takeover()).toBeNull(); + await act(async () => show([request, next])); + expect(takeover()?.textContent).toContain("New review"); + await act(async () => { + if (success) finishSend(); + else rejectSend(new Error("Message not sent")); + }); + expect(takeover()?.textContent).toContain("New review"); + }); + + it("restores the confirmation when sending a separate message fails", async () => { + render(); + await act(async () => editorCallbacks.change("Let's discuss something else first.")); + await act(async () => container.querySelector('[data-testid="task-chat-composer-send"]')!.click()); + expect(takeover()).not.toBeNull(); + expect(container.querySelector('[data-testid="mock-editor"]')?.textContent).toContain("Let's discuss something else first."); + }); + + it.each([false, true])("restores a reopened historical confirmation after a failed optimistic send (rollback=%s)", async rollback => { + const request = { ...genericPendingRequestConfirmationInteraction, createdAt: old.createdAt }; + let rejectSend!: (error: Error) => void; + const onAdd = vi.fn(() => new Promise((_resolve, reject) => { rejectSend = reject; })); + const show = (comments = movedOn) => render(); + show(); + await act(async () => container.querySelector(`[aria-label="Open request: ${request.title}"]`)!.click()); + expect(takeover()).not.toBeNull(); + await act(async () => editorCallbacks.change("Let's discuss something else first.")); + await act(async () => container.querySelector('[data-testid="task-chat-composer-send"]')!.click()); + const optimistic = { ...movedOn[0]!, id: "optimistic-send", createdAt: new Date("2026-08-15T12:10:00Z") }; + await act(async () => show([...movedOn, optimistic])); + expect(takeover()).toBeNull(); + await act(async () => { + if (rollback) show(); + rejectSend(new Error("Message not sent")); + }); + expect(takeover()).not.toBeNull(); + expect(takeover()?.textContent).toContain(request.title); + expect(container.querySelector('[data-testid="mock-editor"]')?.textContent).toContain("Let's discuss something else first."); + }); + + it("does not restore an earlier confirmation after the person opens and dismisses another request during a failed send", async () => { + const first = { ...genericPendingRequestConfirmationInteraction, id: "earlier-review", title: "Earlier review", createdAt: old.createdAt }; + const second = { ...first, id: "later-review", title: "Later review" }; + let rejectSend!: (error: Error) => void; + const onAdd = vi.fn(() => new Promise((_resolve, reject) => { rejectSend = reject; })); + render(); + await act(async () => container.querySelector('[aria-label="Open request: Earlier review"]')!.click()); + await act(async () => editorCallbacks.change("Let's discuss something else first.")); + await act(async () => container.querySelector('[data-testid="task-chat-composer-send"]')!.click()); + await act(async () => container.querySelector('[aria-label="Open request: Later review"]')!.click()); + await dismiss(); + await act(async () => rejectSend(new Error("Message not sent"))); + expect(takeover()).toBeNull(); + }); + it.each(["Cancel", "dismiss"])("leaves only the history card after %s on a fresh question", async action => { render(); await click("Yes"); diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index e420633b04..bcf46e8bdc 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -2786,29 +2786,27 @@ export function TaskChatThread(props: TaskChatThreadProps) { input.kind === "durable" ? Boolean(input.interaction.dismissedAt) || dismissedInputIds.has(input.interaction.id) || dismissedInputIds.has(input.key) : dismissedInputIds.has(input.key), [dismissedInputIds]); - const pendingReminderInputs = useMemo(() => pendingComposerInputs.filter(input => - !isInputDismissed(input) && !(input.kind === "durable" && input.interaction.kind === "ask_user_questions")), - [pendingComposerInputs, isInputDismissed]); const [dismissError, setDismissError] = useState(null); const latestUserComment = useMemo(() => comments .filter(comment => !comment.deletedAt && comment.authorUserId && !comment.authorAgentId && !comment.createdByRunId) .reduce<(typeof comments)[number] | null>((latest, comment) => !latest || toMs(comment.createdAt) >= toMs(latest.createdAt) ? comment : latest, null), [comments]); - const latestUserCommentId = latestUserComment?.id ?? null; - // A later message leaves a question answerable in history, without reopening - // its form on every render or reload. Explicit selection can still reopen it. const currentPendingInputs = useMemo(() => pendingComposerInputs.filter(input => - !isInputDismissed(input) && !(input.kind === "durable" && input.interaction.kind === "ask_user_questions" + !isInputDismissed(input) && !(input.kind === "durable" && latestUserComment && toMs(input.interaction.createdAt) < toMs(latestUserComment.createdAt))), [pendingComposerInputs, isInputDismissed, latestUserComment]); + const pendingReminderInputs = useMemo(() => currentPendingInputs.filter(input => + !(input.kind === "durable" && input.interaction.kind === "ask_user_questions")), + [currentPendingInputs]); const currentPendingKeys = useMemo(() => new Set(currentPendingInputs.map(input => input.key)), [currentPendingInputs]); const [takeoverMode, setTakeoverMode] = useState<"open" | "normal">("open"); const [selectedPendingKey, setSelectedPendingKey] = useState( null, ); const explicitlyOpenedInputRef = useRef(null); + const inputSelectionVersionRef = useRef(0); const knownPendingKeysRef = useRef>(new Set()); - const lastInputUserCommentRef = useRef(latestUserCommentId); + const lastInputUserCommentRef = useRef(latestUserComment); useEffect(() => { const currentKeys = new Set( pendingComposerInputs.map((input) => input.key), @@ -2816,21 +2814,27 @@ export function TaskChatThread(props: TaskChatThreadProps) { const hasNewInput = pendingComposerInputs.some( (input) => currentPendingKeys.has(input.key) && !knownPendingKeysRef.current.has(input.key), ); - const movedOn = lastInputUserCommentRef.current !== latestUserCommentId; - lastInputUserCommentRef.current = latestUserCommentId; + const previousUserComment = lastInputUserCommentRef.current; + const movedOn = latestUserComment && previousUserComment?.id !== latestUserComment.id + && (!previousUserComment || toMs(latestUserComment.createdAt) >= toMs(previousUserComment.createdAt)); + lastInputUserCommentRef.current = latestUserComment; knownPendingKeysRef.current = currentKeys; setSelectedPendingKey((current) => { + if (hasNewInput) return currentPendingInputs[0]?.key ?? null; const currentInput = pendingComposerInputs.find(input => input.key === current); - const questionMovedOn = movedOn && currentInput?.kind === "durable" - && currentInput.interaction.kind === "ask_user_questions" && !currentPendingKeys.has(currentInput.key); + const requestMovedOn = movedOn && currentInput?.kind === "durable" + && !isInputDismissed(currentInput) && !currentPendingKeys.has(currentInput.key); return current && currentKeys.has(current) && (currentPendingKeys.has(current) || explicitlyOpenedInputRef.current === current) - && !questionMovedOn + && !requestMovedOn ? current : (currentPendingInputs[0]?.key ?? null); }); - if (hasNewInput) setTakeoverMode("open"); - }, [pendingComposerInputs, latestUserCommentId, currentPendingInputs, currentPendingKeys]); + if (hasNewInput) { + inputSelectionVersionRef.current += 1; + setTakeoverMode("open"); + } + }, [pendingComposerInputs, latestUserComment, currentPendingInputs, currentPendingKeys, isInputDismissed]); const selectedPendingInput = pendingComposerInputs.find((input) => input.key === selectedPendingKey) ?? currentPendingInputs[0] ?? @@ -2843,6 +2847,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { ? `paperclip:task-input:${issueId ?? "unknown"}:${selectedPendingInput.key}` : undefined; const openPendingTakeover = useCallback(() => { + inputSelectionVersionRef.current += 1; if (!pendingReminderInputs.some(input => input.key === selectedPendingInput?.key)) { setSelectedPendingKey(pendingReminderInputs[0]?.key ?? null); } @@ -2850,6 +2855,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { }, [pendingReminderInputs, selectedPendingInput]); const showNextPendingInput = useCallback(() => { if (pendingReminderInputs.length < 2) return; + inputSelectionVersionRef.current += 1; const currentIndex = pendingReminderInputs.findIndex( (input) => input.key === selectedPendingInput?.key, ); @@ -2860,6 +2866,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { }, [pendingReminderInputs, selectedPendingInput?.key]); const skipPendingInput = useCallback( async (input: PendingComposerInput) => { + inputSelectionVersionRef.current += 1; if (input.kind === "runtime") { await handleRuntimeRequestDecision(input.item, { action: "cancel" }); } else { @@ -2880,19 +2887,23 @@ export function TaskChatThread(props: TaskChatThreadProps) { agentId: args[2].assigneeAgentId, agent: agentMap?.get(args[2].assigneeAgentId ?? ""), } : undefined); + const selectionVersion = ++inputSelectionVersionRef.current; + explicitlyOpenedInputRef.current = null; + setTakeoverMode("normal"); try { await onAdd(...args); - if (selectedPendingInput?.kind === "durable" && selectedPendingInput.interaction.kind === "ask_user_questions") { - dismissInput(selectedPendingInput.interaction.id); - setSelectedPendingKey(null); - setTakeoverMode("normal"); - } + dismissInput(pendingComposerInputs.map(input => input.kind === "durable" ? input.interaction.id : input.key)); } catch (error) { + if (inputSelectionVersionRef.current === selectionVersion) { + explicitlyOpenedInputRef.current = selectedPendingInput?.key ?? null; + setSelectedPendingKey(selectedPendingInput?.key ?? null); + setTakeoverMode(takeoverMode); + } rejectSubmission(attempt, error); throw error; } }, - [beginSubmission, rejectSubmission, onAdd, selectedPendingInput, dismissInput, agentMap], + [beginSubmission, rejectSubmission, onAdd, pendingComposerInputs, selectedPendingInput, dismissInput, agentMap, takeoverMode], ); const optimisticRunnerStartup = hasAgentAssignee && @@ -2972,6 +2983,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { ); const reopenToolReview = useCallback((interactionId: string) => { + inputSelectionVersionRef.current += 1; const key = pendingComposerInputs.find(input => input.kind === "durable" && input.interaction.id === interactionId)?.key ?? `interaction:${interactionId}`; explicitlyOpenedInputRef.current = key; setSelectedPendingKey(key); @@ -3090,6 +3102,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { pendingCount: Math.max(1, pendingReminderInputs.length), content: takeoverContent, onDismiss: () => { + inputSelectionVersionRef.current += 1; void (async () => { setDismissError(null); try { diff --git a/ui/src/hooks/useDismissedTaskQuestions.ts b/ui/src/hooks/useDismissedTaskQuestions.ts index b390d138d1..ad69f706b5 100644 --- a/ui/src/hooks/useDismissedTaskQuestions.ts +++ b/ui/src/hooks/useDismissedTaskQuestions.ts @@ -16,10 +16,10 @@ export function useDismissedTaskQuestions(issueId?: string | null, userId?: stri const [state, setState] = useState(() => ({ storageKey, ids: loadDismissedQuestions(storageKey) })); const dismissedQuestionIds = useMemo(() => state.storageKey === storageKey ? state.ids : loadDismissedQuestions(storageKey), [state, storageKey]); - const dismissQuestion = useCallback((interactionId: string) => { + const dismissQuestion = useCallback((interactionId: string | readonly string[]) => { // Another tab may have saved more dismissals since this thread rendered. const ids = new Set([...loadDismissedQuestions(storageKey), ...dismissedQuestionIds]); - ids.add(interactionId); + for (const id of typeof interactionId === "string" ? [interactionId] : interactionId) ids.add(id); try { if (storageKey) localStorage.setItem(storageKey, JSON.stringify([...ids])); } catch { diff --git a/ui/storybook/fixtures/unansweredQuestionChat.tsx b/ui/storybook/fixtures/unansweredQuestionChat.tsx index a15985d9df..5ff7e919bc 100644 --- a/ui/storybook/fixtures/unansweredQuestionChat.tsx +++ b/ui/storybook/fixtures/unansweredQuestionChat.tsx @@ -33,7 +33,7 @@ export function QuestionChat({ movedOn = false, multiple = false, answered = fal return client; }); const [interactions, setInteractions] = useState([ - ...(review ? [{ ...genericPendingRequestConfirmationInteraction, id: "completion-review", title: "Review requested", summary: "Provide app configuration to finish integration.", payload: { version: 1 as const, prompt: "Provide app configuration to finish integration.", acceptLabel: "Approve completion", rejectLabel: "Continue work", target: { type: "custom" as const, key: "native_completion_review", label: "Completion decision" } } }] : []), + ...(review ? [{ ...genericPendingRequestConfirmationInteraction, id: "completion-review", createdAt: question.createdAt, title: "Review requested", summary: "Provide app configuration to finish integration.", payload: { version: 1 as const, prompt: "Provide app configuration to finish integration.", acceptLabel: "Approve completion", rejectLabel: "Continue work", target: { type: "custom" as const, key: "native_completion_review", label: "Completion decision" } } }] : []), ...(review ? [] : [answered ? { ...question, status: "answered" as const, resolvedAt: new Date("2026-04-01T12:06:00Z"), result: { version: 1 as const, answers: [{ questionId: "color", optionIds: ["blue"] }] } } : question]), ...(multiple ? [otherQuestion] : []), ]); diff --git a/ui/storybook/stories/questions-and-approvals/task-chat-unanswered-questions.stories.tsx b/ui/storybook/stories/questions-and-approvals/task-chat-unanswered-questions.stories.tsx index 3baaee007f..132a9bfb31 100644 --- a/ui/storybook/stories/questions-and-approvals/task-chat-unanswered-questions.stories.tsx +++ b/ui/storybook/stories/questions-and-approvals/task-chat-unanswered-questions.stories.tsx @@ -22,9 +22,42 @@ const meta = { export default meta; type Story = StoryObj; +function resetDismissals() { + localStorage.removeItem("paperclip:task-question-dismissals:user-board:task-unanswered-questions-demo"); +} + +async function sendInsteadOfConfirming(canvasElement: HTMLElement) { + const canvas = within(canvasElement); + await waitFor(() => expect(canvas.getByTestId("task-chat-composer-takeover")).toBeVisible()); + await userEvent.type(canvas.getByRole("textbox", { name: "editable markdown" }), "Leave that review for later. Show me the integration status."); + await userEvent.click(canvas.getByRole("button", { name: /^Send$/ })); + await expect(canvas.queryByTestId("task-chat-composer-takeover")).not.toBeInTheDocument(); + await expect(canvas.queryByTestId("task-chat-pending-input-indicator")).not.toBeInTheDocument(); + await expect(canvas.getByRole("button", { name: "Open request: Review requested" })).toBeVisible(); +} + // Keep the test-drive story's dismissal preference across actual page reloads. export const TestDrive: Story = {}; export const CompletionReview: Story = { args: { review: true } }; +export const SendInsteadOfConfirming: Story = { + args: { review: true }, + beforeEach: resetDismissals, + play: async ({ canvasElement }) => { + await sendInsteadOfConfirming(canvasElement); + }, +}; +export const ReopenSkippedConfirmation: Story = { + args: { review: true }, + beforeEach: resetDismissals, + play: async ({ canvasElement }) => { + await sendInsteadOfConfirming(canvasElement); + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "Open request: Review requested" })); + await expect(canvas.getByTestId("task-chat-composer-takeover")).toBeVisible(); + await expect(canvas.getByRole("button", { name: "Approve completion" })).toBeVisible(); + await expect(canvas.getByRole("button", { name: "Continue work" })).toBeVisible(); + }, +}; export const MovedOn: Story = { args: { movedOn: true } }; export const MultipleUnanswered: Story = { args: { multiple: true } }; export const AnsweredHistory: Story = { args: { answered: true } }; @@ -44,6 +77,6 @@ export const DismissAndAnswerLater: Story = { await userEvent.click(canvas.getByTestId("task-chat-unanswered-question")); await expect(canvas.getByRole("radio", { name: "Green" })).toBeChecked(); await userEvent.click(canvas.getByRole("button", { name: /^(Send|Submit) answers$/ })); - await waitFor(() => expect(canvas.getByTestId("task-chat-answered-questions-receipt")).toBeVisible()); + await waitFor(() => expect(canvas.getByText("Answers recorded", { exact: true })).toBeVisible()); }, };