fix(mobile-chat): remove sending gate from attach button + banner-clear coverage (follow-up to #2762) #2795
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user