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