fix(ui): restore reopened confirmations after failed sends

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-11-06 10:33:37 -06:00
1 parent 098c2bc357
commit fda44f2648
2 files changed
+64 -12

No files matched your search

+44 -3
View File
@@ -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<void>(resolve => { finishSend = resolve; }));
let rejectSend!: (error: Error) => void;
const onAdd = vi.fn(() => new Promise<void>((resolve, reject) => { finishSend = resolve; rejectSend = reject; }));
const show = (interactions = [request]) => render(<TaskChatThread {...props} comments={[]} interactions={interactions} onAdd={onAdd} />);
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<void>((_resolve, reject) => { rejectSend = reject; }));
const show = (comments = movedOn) => render(<TaskChatThread {...props} comments={comments} interactions={[request]} onAdd={onAdd} />);
show();
await act(async () => container.querySelector<HTMLButtonElement>(`[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<HTMLButtonElement>('[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<void>((_resolve, reject) => { rejectSend = reject; }));
render(<TaskChatThread {...props} comments={movedOn} interactions={[first, second]} onAdd={onAdd} />);
await act(async () => container.querySelector<HTMLButtonElement>('[aria-label="Open request: Earlier review"]')!.click());
await act(async () => editorCallbacks.change("Let's discuss something else first."));
await act(async () => container.querySelector<HTMLButtonElement>('[data-testid="task-chat-composer-send"]')!.click());
await act(async () => container.querySelector<HTMLButtonElement>('[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(<TaskChatThread {...props} comments={[]} interactions={[old]} />);
await click("Yes");
+20 -9
View File
@@ -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<string | null>(null);
const inputSelectionVersionRef = useRef(0);
const knownPendingKeysRef = useRef<Set<string>>(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 {