diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index ab28a875fba..429ff516f0d 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -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( + + + + {}} + enableReassign + reassignOptions={[ + { id: "", label: "No assignee" }, + { id: "agent:agent-1", label: "Agent 1" }, + ]} + currentAssigneeValue="" + suggestedAssigneeValue="" + enableLiveTranscriptPolling={false} + /> + + , + ); + }); + + 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( + + + + {}} + enableReassign + reassignOptions={[ + { id: "", label: "No assignee" }, + { id: "agent:agent-1", label: "Agent 1" }, + ]} + currentAssigneeValue="agent:agent-1" + suggestedAssigneeValue="agent:agent-1" + enableLiveTranscriptPolling={false} + /> + + , + ); + }); + + 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 }>(); diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 5f647e79861..6d61a0d8fb6 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -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(null); const editorRef = useRef(null); const composerContainerRef = useRef(null); @@ -2429,6 +2436,10 @@ const IssueChatComposer = forwardRef { + setUnassignedConfirmed(false); + }, [reassignTarget]); + useImperativeHandle(forwardedRef, () => ({ focus: focusComposer, restoreDraft: (submittedBody: string) => { @@ -2446,6 +2457,22 @@ const IssueChatComposer = forwardRef 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