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:
DottaandPaperclip committed 2026-04-28 16:48:58 -05:00
1 parent 68ae4cdb36
commit c19dc328a9
3 files changed
+155

No files matched your search

+123
View File
@@ -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 }>();
+28
View File
@@ -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",
+4
View File
@@ -186,6 +186,10 @@ export function useToastActions() {
return context;
}
export function useOptionalToastActions() {
return useContext(ToastActionsContext);
}
export function useToast() {
const toasts = useToastState();
const actions = useToastActions();