mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 16:02:36 +01:00
fix(ui): dismiss skipped confirmations when sending a message
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
e2f0fc9e7d
commit
098c2bc357
7 files changed
+141
-20
No files matched your search
@@ -576,6 +576,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,
|
||||
|
||||
@@ -1975,6 +1975,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
|
||||
|
||||
@@ -3950,6 +3950,82 @@ 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<void>(resolve => { finishSend = resolve; }));
|
||||
const onAcceptInteraction = vi.fn();
|
||||
const onRejectInteraction = vi.fn();
|
||||
const show = () => render(<TaskChatThread {...props} comments={[]} interactions={[request]}
|
||||
onAdd={onAdd} onAcceptInteraction={onAcceptInteraction} onRejectInteraction={onRejectInteraction} />);
|
||||
show();
|
||||
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());
|
||||
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<HTMLButtonElement>(`[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<HTMLButtonElement>('[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(<TaskChatThread {...props} comments={comments} interactions={[request]} />);
|
||||
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<HTMLButtonElement>(`[aria-label="Open request: ${request.title}"]`)!.click());
|
||||
expect(takeover()).not.toBeNull();
|
||||
});
|
||||
|
||||
it("keeps a new confirmation open when an earlier message finishes sending", async () => {
|
||||
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; }));
|
||||
const show = (interactions = [request]) => render(<TaskChatThread {...props} comments={[]} interactions={interactions} onAdd={onAdd} />);
|
||||
show();
|
||||
await act(async () => editorCallbacks.change("Let's discuss something else first."));
|
||||
await act(async () => container.querySelector<HTMLButtonElement>('[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 () => finishSend());
|
||||
expect(takeover()?.textContent).toContain("New review");
|
||||
});
|
||||
|
||||
it("restores the confirmation when sending a separate message fails", async () => {
|
||||
render(<TaskChatThread {...props} comments={[]} interactions={[genericPendingRequestConfirmationInteraction]}
|
||||
onAdd={vi.fn().mockRejectedValue(new Error("Message not sent"))} />);
|
||||
await act(async () => editorCallbacks.change("Let's discuss something else first."));
|
||||
await act(async () => container.querySelector<HTMLButtonElement>('[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(["Cancel", "dismiss"])("leaves only the history card after %s on a fresh question", async action => {
|
||||
render(<TaskChatThread {...props} comments={[]} interactions={[old]} />);
|
||||
await click("Yes");
|
||||
|
||||
@@ -2773,21 +2773,19 @@ 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<string | null>(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<string | null>(
|
||||
@@ -2807,17 +2805,18 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
lastInputUserCommentRef.current = latestUserCommentId;
|
||||
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]);
|
||||
}, [pendingComposerInputs, latestUserCommentId, currentPendingInputs, currentPendingKeys, isInputDismissed]);
|
||||
const selectedPendingInput =
|
||||
pendingComposerInputs.find((input) => input.key === selectedPendingKey) ??
|
||||
currentPendingInputs[0] ??
|
||||
@@ -2867,19 +2866,22 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
agentId: args[2].assigneeAgentId,
|
||||
agent: agentMap?.get(args[2].assigneeAgentId ?? ""),
|
||||
} : undefined);
|
||||
const previouslyOpenedInput = explicitlyOpenedInputRef.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 (selectedPendingInputKeyRef.current === selectedPendingInput?.key) {
|
||||
explicitlyOpenedInputRef.current = previouslyOpenedInput;
|
||||
setTakeoverMode(takeoverMode);
|
||||
}
|
||||
rejectSubmission(attempt, error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[beginSubmission, rejectSubmission, onAdd, selectedPendingInput, dismissInput, agentMap],
|
||||
[beginSubmission, rejectSubmission, onAdd, pendingComposerInputs, selectedPendingInput, dismissInput, agentMap, takeoverMode],
|
||||
);
|
||||
const optimisticRunnerStartup =
|
||||
hasAgentAssignee &&
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -33,7 +33,7 @@ export function QuestionChat({ movedOn = false, multiple = false, answered = fal
|
||||
return client;
|
||||
});
|
||||
const [interactions, setInteractions] = useState<IssueThreadInteraction[]>([
|
||||
...(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] : []),
|
||||
]);
|
||||
|
||||
+34
-1
@@ -22,9 +22,42 @@ const meta = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
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());
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user