From fda44f2648902807834f7d6d729e90c53726b208 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:33:37 -0600 Subject: [PATCH] fix(ui): restore reopened confirmations after failed sends Co-Authored-By: Paperclip --- ui/src/components/TaskChatThread.test.tsx | 47 +++++++++++++++++++++-- ui/src/components/TaskChatThread.tsx | 29 +++++++++----- 2 files changed, 64 insertions(+), 12 deletions(-) diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx index 468cd24e9a6..d41e09f21bd 100644 --- a/ui/src/components/TaskChatThread.test.tsx +++ b/ui/src/components/TaskChatThread.test.tsx @@ -4001,11 +4001,12 @@ describe.each([true, false])("Unanswered question history (conversationMode=%s)" expect(takeover()).not.toBeNull(); }); - it("keeps a new confirmation open when an earlier message finishes sending", async () => { + 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; - const onAdd = vi.fn(() => new Promise(resolve => { finishSend = resolve; })); + 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.")); @@ -4013,7 +4014,10 @@ describe.each([true, false])("Unanswered question history (conversationMode=%s)" expect(takeover()).toBeNull(); await act(async () => show([request, next])); expect(takeover()?.textContent).toContain("New review"); - await act(async () => finishSend()); + await act(async () => { + if (success) finishSend(); + else rejectSend(new Error("Message not sent")); + }); expect(takeover()?.textContent).toContain("New review"); }); @@ -4026,6 +4030,43 @@ describe.each([true, false])("Unanswered question history (conversationMode=%s)" 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 3ead170180a..7dc4fcff0a9 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -2778,7 +2778,6 @@ export function TaskChatThread(props: TaskChatThreadProps) { .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; const currentPendingInputs = useMemo(() => pendingComposerInputs.filter(input => !isInputDismissed(input) && !(input.kind === "durable" && latestUserComment && toMs(input.interaction.createdAt) < toMs(latestUserComment.createdAt))), @@ -2792,8 +2791,9 @@ export function TaskChatThread(props: TaskChatThreadProps) { 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), @@ -2801,8 +2801,10 @@ 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; @@ -2815,8 +2817,11 @@ export function TaskChatThread(props: TaskChatThreadProps) { ? current : (currentPendingInputs[0]?.key ?? null); }); - if (hasNewInput) setTakeoverMode("open"); - }, [pendingComposerInputs, latestUserCommentId, currentPendingInputs, currentPendingKeys, isInputDismissed]); + if (hasNewInput) { + inputSelectionVersionRef.current += 1; + setTakeoverMode("open"); + } + }, [pendingComposerInputs, latestUserComment, currentPendingInputs, currentPendingKeys, isInputDismissed]); const selectedPendingInput = pendingComposerInputs.find((input) => input.key === selectedPendingKey) ?? currentPendingInputs[0] ?? @@ -2829,6 +2834,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); } @@ -2836,6 +2842,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, ); @@ -2846,6 +2853,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 { @@ -2866,15 +2874,16 @@ export function TaskChatThread(props: TaskChatThreadProps) { agentId: args[2].assigneeAgentId, agent: agentMap?.get(args[2].assigneeAgentId ?? ""), } : undefined); - const previouslyOpenedInput = explicitlyOpenedInputRef.current; + const selectionVersion = ++inputSelectionVersionRef.current; explicitlyOpenedInputRef.current = null; setTakeoverMode("normal"); try { await onAdd(...args); dismissInput(pendingComposerInputs.map(input => input.kind === "durable" ? input.interaction.id : input.key)); } catch (error) { - if (selectedPendingInputKeyRef.current === selectedPendingInput?.key) { - explicitlyOpenedInputRef.current = previouslyOpenedInput; + if (inputSelectionVersionRef.current === selectionVersion) { + explicitlyOpenedInputRef.current = selectedPendingInput?.key ?? null; + setSelectedPendingKey(selectedPendingInput?.key ?? null); setTakeoverMode(takeoverMode); } rejectSubmission(attempt, error); @@ -2961,6 +2970,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); @@ -3079,6 +3089,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { pendingCount: Math.max(1, pendingReminderInputs.length), content: takeoverContent, onDismiss: () => { + inputSelectionVersionRef.current += 1; void (async () => { setDismissError(null); try {