fix(mobile-chat): remove sending gate from attach button + banner-clear coverage (follow-up to #2762) #2795

Merged
devops-engineer merged 1 commits from fix/2762-mobile-attach-sending-gate into main 2026-06-14 00:51:28 +00:00
2 changed files with 73 additions and 4 deletions
+8 -3
View File
@@ -767,7 +767,12 @@ export function MobileChat({
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={!reachable || sending || uploading}
// Multi-send parity (core#2726 / CR2 #2762): the attach button
// must NOT disable while a send is in flight, so the user can add
// attachments to a follow-up message while the agent is still
// working. Keep the uploading gate (single concurrent upload) and
// the reachable gate (offline agent).
disabled={!reachable || uploading}
aria-label="Attach"
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
style={{
@@ -775,14 +780,14 @@ export function MobileChat({
height: 32,
borderRadius: 999,
border: "none",
cursor: reachable && !sending && !uploading ? "pointer" : "not-allowed",
cursor: reachable && !uploading ? "pointer" : "not-allowed",
background: "transparent",
color: p.text3,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
opacity: !reachable || sending || uploading ? 0.4 : 1,
opacity: !reachable || uploading ? 0.4 : 1,
}}
>
{Icons.attach({ size: 16 })}
@@ -678,8 +678,11 @@ describe("MobileChat — thinking indicator (core#2720/#2745 parity)", () => {
});
describe("MobileChat — multi-send tap path (CR2 #2762)", () => {
it("Send button stays ENABLED during an in-flight send (tap multi-send)", async () => {
beforeEach(() => {
mockStoreState.nodes = [onlineNode];
});
it("Send button stays ENABLED during an in-flight send (tap multi-send)", async () => {
// First send hangs → sending stays true.
vi.spyOn(api, "post").mockReturnValueOnce(new Promise(() => {}));
const { container } = renderChat(mockAgentId);
@@ -698,4 +701,65 @@ describe("MobileChat — multi-send tap path (CR2 #2762)", () => {
});
expect(sendBtn().disabled).toBe(false);
});
it("Attach button stays ENABLED during an in-flight send (core#2762 follow-up)", async () => {
// First send hangs → sending stays true.
vi.spyOn(api, "post").mockReturnValueOnce(new Promise(() => {}));
const { container } = renderChat(mockAgentId);
const ta = container.querySelector("textarea") as HTMLTextAreaElement;
const sendBtn = container.querySelector('[aria-label="Send"]') as HTMLButtonElement;
const attachBtn = container.querySelector('[aria-label="Attach"]') as HTMLButtonElement;
await act(async () => {
fireEvent.change(ta, { target: { value: "first" } });
});
await act(async () => {
sendBtn.click();
});
// Attach must remain usable while the send is in flight.
expect(attachBtn.disabled).toBe(false);
// Selecting a file while sending should add it to the pending list.
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
const file = new File(["hello"], "note.txt", { type: "text/plain" });
await act(async () => {
fireEvent.change(fileInput, { target: { files: [file] } });
});
expect(container.textContent ?? "").toContain("note.txt");
});
it("clears the send-error banner when a follow-up send starts (multi-send banner-clear)", async () => {
const postSpy = vi.spyOn(api, "post");
// First send fails → error banner.
postSpy.mockRejectedValueOnce(new Error("boom"));
// Second send succeeds → banner must clear.
postSpy.mockResolvedValueOnce({ result: { parts: [] } });
const { container } = renderChat(mockAgentId);
const ta = container.querySelector("textarea") as HTMLTextAreaElement;
const sendBtn = container.querySelector('[aria-label="Send"]') as HTMLButtonElement;
await act(async () => {
fireEvent.change(ta, { target: { value: "first" } });
});
await act(async () => {
sendBtn.click();
});
await waitFor(() => {
expect(container.textContent ?? "").toContain("unreachable");
});
// Start a follow-up send; the banner should clear as soon as the new
// send is dispatched (send() calls clearError before awaiting the POST).
await act(async () => {
fireEvent.change(ta, { target: { value: "second" } });
});
await act(async () => {
sendBtn.click();
});
await waitFor(() => {
expect(container.textContent ?? "").not.toContain("unreachable");
});
});
});