mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 07:10:34 +01:00
fix(ui): restore reopened confirmations after failed sends
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
098c2bc357
commit
fda44f2648
2 files changed
+64
-12
No files matched your search
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user