mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 06:04:11 +01:00
PAP-2659: Warn before sending issue reply with no assignee
If the issue reply composer would post without any assignee, the first Send press now surfaces a warning toast instead of submitting. A second press confirms the user wants to proceed unassigned, and changing the assignee resets the confirmation. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
68ae4cdb36
commit
c19dc328a9
3 files changed
+155
No files matched your search
@@ -13,6 +13,8 @@ import {
|
||||
resolveAssistantMessageFoldedState,
|
||||
resolveIssueChatHumanAuthor,
|
||||
} from "./IssueChatThread";
|
||||
import { ToastProvider } from "../context/ToastContext";
|
||||
import { ToastViewport } from "./ToastViewport";
|
||||
import type {
|
||||
AskUserQuestionsInteraction,
|
||||
RequestConfirmationInteraction,
|
||||
@@ -1617,6 +1619,127 @@ describe("IssueChatThread", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("warns once before sending a reply with no assignee selected", async () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<ToastProvider>
|
||||
<ToastViewport />
|
||||
<MemoryRouter>
|
||||
<IssueChatThread
|
||||
comments={[]}
|
||||
linkedRuns={[]}
|
||||
timelineEvents={[]}
|
||||
liveRuns={[]}
|
||||
onAdd={async () => {}}
|
||||
enableReassign
|
||||
reassignOptions={[
|
||||
{ id: "", label: "No assignee" },
|
||||
{ id: "agent:agent-1", label: "Agent 1" },
|
||||
]}
|
||||
currentAssigneeValue=""
|
||||
suggestedAssigneeValue=""
|
||||
enableLiveTranscriptPolling={false}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</ToastProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
const editor = container.querySelector('textarea[aria-label="Issue chat editor"]') as HTMLTextAreaElement | null;
|
||||
const submitButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(element) => element.textContent === "Send",
|
||||
) as HTMLButtonElement | undefined;
|
||||
expect(editor).not.toBeNull();
|
||||
expect(submitButton).toBeDefined();
|
||||
|
||||
act(() => {
|
||||
const valueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLTextAreaElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
valueSetter?.call(editor, "Reply without assignee");
|
||||
editor?.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
submitButton?.click();
|
||||
});
|
||||
|
||||
expect(appendMock).not.toHaveBeenCalled();
|
||||
expect(document.body.textContent).toContain("No assignee selected");
|
||||
|
||||
await act(async () => {
|
||||
submitButton?.click();
|
||||
});
|
||||
|
||||
expect(appendMock).toHaveBeenCalledTimes(1);
|
||||
expect(appendMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
content: [{ type: "text", text: "Reply without assignee" }],
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not warn when sending a reply with an assignee selected", async () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<ToastProvider>
|
||||
<ToastViewport />
|
||||
<MemoryRouter>
|
||||
<IssueChatThread
|
||||
comments={[]}
|
||||
linkedRuns={[]}
|
||||
timelineEvents={[]}
|
||||
liveRuns={[]}
|
||||
onAdd={async () => {}}
|
||||
enableReassign
|
||||
reassignOptions={[
|
||||
{ id: "", label: "No assignee" },
|
||||
{ id: "agent:agent-1", label: "Agent 1" },
|
||||
]}
|
||||
currentAssigneeValue="agent:agent-1"
|
||||
suggestedAssigneeValue="agent:agent-1"
|
||||
enableLiveTranscriptPolling={false}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</ToastProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
const editor = container.querySelector('textarea[aria-label="Issue chat editor"]') as HTMLTextAreaElement | null;
|
||||
const submitButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(element) => element.textContent === "Send",
|
||||
) as HTMLButtonElement | undefined;
|
||||
|
||||
act(() => {
|
||||
const valueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLTextAreaElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
valueSetter?.call(editor, "Reply with assignee");
|
||||
editor?.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
submitButton?.click();
|
||||
});
|
||||
|
||||
expect(appendMock).toHaveBeenCalledTimes(1);
|
||||
expect(document.body.textContent).not.toContain("No assignee selected");
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes a composer focus handle that forwards to the editor", () => {
|
||||
const root = createRoot(container);
|
||||
const composerRef = createRef<{ focus: () => void; restoreDraft: (submittedBody: string) => void }>();
|
||||
|
||||
@@ -90,6 +90,7 @@ import {
|
||||
shouldPreserveComposerViewport,
|
||||
} from "../lib/issue-chat-scroll";
|
||||
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||
import { useOptionalToastActions } from "../context/ToastContext";
|
||||
import type { CompanyUserProfile } from "../lib/company-members";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import {
|
||||
@@ -546,6 +547,10 @@ function shouldImplicitlyReopenComment(issueStatus: string | undefined, assignee
|
||||
return resumesToTodo && assigneeValue.startsWith("agent:");
|
||||
}
|
||||
|
||||
function isUnassignedReassignValue(value: string): boolean {
|
||||
return !value || value === "__none__";
|
||||
}
|
||||
|
||||
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
function commentDateLabel(date: Date | string | undefined): string {
|
||||
@@ -2375,6 +2380,7 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
||||
issueStatus,
|
||||
}, forwardedRef) {
|
||||
const api = useAui();
|
||||
const toastActions = useOptionalToastActions();
|
||||
const [body, setBody] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [attaching, setAttaching] = useState(false);
|
||||
@@ -2383,6 +2389,7 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
||||
const dragDepthRef = useRef(0);
|
||||
const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue;
|
||||
const [reassignTarget, setReassignTarget] = useState(effectiveSuggestedAssigneeValue);
|
||||
const [unassignedConfirmed, setUnassignedConfirmed] = useState(false);
|
||||
const attachInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const editorRef = useRef<MarkdownEditorRef>(null);
|
||||
const composerContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -2429,6 +2436,10 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
||||
setReassignTarget(effectiveSuggestedAssigneeValue);
|
||||
}, [effectiveSuggestedAssigneeValue]);
|
||||
|
||||
useEffect(() => {
|
||||
setUnassignedConfirmed(false);
|
||||
}, [reassignTarget]);
|
||||
|
||||
useImperativeHandle(forwardedRef, () => ({
|
||||
focus: focusComposer,
|
||||
restoreDraft: (submittedBody: string) => {
|
||||
@@ -2446,6 +2457,22 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed || submitting) return;
|
||||
|
||||
const composerHasAssigneePicker = enableReassign && reassignOptions.length > 0;
|
||||
if (
|
||||
composerHasAssigneePicker
|
||||
&& isUnassignedReassignValue(reassignTarget)
|
||||
&& !unassignedConfirmed
|
||||
) {
|
||||
toastActions?.pushToast({
|
||||
title: "No assignee selected",
|
||||
body: "Pick an assignee or click Send again to post without one.",
|
||||
tone: "warn",
|
||||
dedupeKey: `issue-chat-no-assignee:${draftKey ?? ""}`,
|
||||
});
|
||||
setUnassignedConfirmed(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const hasReassignment = enableReassign && reassignTarget !== currentAssigneeValue;
|
||||
const reassignment = hasReassignment ? parseReassignment(reassignTarget) : undefined;
|
||||
const reopen = shouldImplicitlyReopenComment(
|
||||
@@ -2457,6 +2484,7 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
||||
|
||||
setSubmitting(true);
|
||||
setBody("");
|
||||
setUnassignedConfirmed(false);
|
||||
try {
|
||||
const appendPromise = api.thread().append({
|
||||
role: "user",
|
||||
|
||||
@@ -186,6 +186,10 @@ export function useToastActions() {
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useOptionalToastActions() {
|
||||
return useContext(ToastActionsContext);
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const toasts = useToastState();
|
||||
const actions = useToastActions();
|
||||
|
||||
Reference in new issue
Block a user