Compare commits

...

1 Commits

Author SHA1 Message Date
Naiyuan Qing
79dc8cfc6e fix(composer): lock editor + button loading during send, clear only on success
Sending a comment on a slow connection looked broken: the comment showed
up in the timeline (server insert + comment:created WS broadcast) while the
composer still held the text with a spinning button — looking like it was
both posted and stuck pending. Root cause is that the composer cleared only
*after* awaiting the network, with no in-flight lock, so the gap between
"server accepted" and "input cleared" was visible.

Switch all three composers (comment, reply, chat) to a pessimistic submit
that matches TanStack Query's form guidance (reset after success, not
optimistically):

- While the send is in flight, lock the editor (pointer-events-none + dim,
  the documented way to make ContentEditor non-interactive — it can't toggle
  Tiptap's `editable` post-mount) and keep the submit button in its loading
  state. The text stays put, clearly reading as "sending".
- Clear the composer (editor + draft) ONLY once the server accepts. A failed
  send now keeps the draft instead of silently discarding it — submitComment
  / submitReply return a success boolean the composer gates the clear on,
  fixing a pre-existing case where a swallowed error still cleared the box.

Chat already cleared on success and disabled its button while submitting; it
only needed the in-flight editor lock for parity.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 14:39:20 +08:00
6 changed files with 116 additions and 27 deletions

View File

@@ -264,7 +264,16 @@ export function ChatInput({
)}
aria-disabled={noAgent || undefined}
>
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-2">
{/* Lock the editor while a send is in flight (mirrors the no-agent
lock and the comment/reply composers): keep the text visible but
non-interactive until onSend settles and we clear on success. */}
<div
className={cn(
"flex-1 min-h-0 overflow-y-auto px-3 py-2",
isSubmitting && "pointer-events-none opacity-60",
)}
aria-busy={isSubmitting || undefined}
>
<ContentEditor
// See the editorKey / draftKey split note above — editorKey
// intentionally does not depend on activeSessionId.

View File

@@ -103,7 +103,7 @@ interface CommentCardProps {
* `CommentRow` has to rerun the rule per row.
*/
canModerate?: boolean;
onReply: (parentId: string, content: string, attachmentIds?: string[], suppressAgentIds?: string[]) => Promise<void>;
onReply: (parentId: string, content: string, attachmentIds?: string[], suppressAgentIds?: string[]) => Promise<boolean>;
onEdit: (commentId: string, content: string, attachmentIds: string[], suppressAgentIds?: string[]) => Promise<void>;
onDelete: (commentId: string) => void;
onToggleReaction: (commentId: string, emoji: string) => void;

View File

@@ -88,16 +88,16 @@ function renderWithProviders(ui: ReactNode) {
);
}
function renderCommentInput(onSubmit = vi.fn().mockResolvedValue(undefined)) {
function renderCommentInput(onSubmit = vi.fn().mockResolvedValue(true)) {
const view = renderWithProviders(<CommentInput issueId="issue-1" onSubmit={onSubmit} />);
return { ...view, onSubmit };
}
function renderReplyInput({
onSubmit = vi.fn().mockResolvedValue(undefined),
onSubmit = vi.fn().mockResolvedValue(true),
size = "sm",
}: {
onSubmit?: (content: string, attachmentIds?: string[], suppressAgentIds?: string[]) => Promise<void>;
onSubmit?: (content: string, attachmentIds?: string[], suppressAgentIds?: string[]) => Promise<boolean>;
size?: "sm" | "default";
} = {}) {
const view = renderWithProviders(
@@ -184,4 +184,42 @@ describe("comment composers", () => {
expect(onSubmit).toHaveBeenCalledWith("thread reply", undefined, undefined);
});
});
it("locks the editor while the send is in flight, then clears on success", async () => {
let resolveSubmit: (ok: boolean) => void = () => {};
const onSubmit = vi.fn(
() => new Promise<boolean>((resolve) => { resolveSubmit = resolve; }),
);
const { container } = renderCommentInput(onSubmit);
fireEvent.change(screen.getByTestId("editor"), { target: { value: "sending" } });
fireEvent.click(getSubmitButton(container));
// In flight: text kept, editor wrapper locked (aria-busy), not cleared yet.
await waitFor(() =>
expect(screen.getByTestId("editor").closest("[aria-busy]")).toHaveAttribute(
"aria-busy",
"true",
),
);
expect(onSubmit).toHaveBeenCalledWith("sending", undefined, undefined);
resolveSubmit(true);
// Success: the composer clears (now empty → submit disabled, lock released).
await waitFor(() => expect(getSubmitButton(container)).toBeDisabled());
expect(screen.getByTestId("editor").closest("[aria-busy]")).toBeNull();
});
it("keeps the draft when the send fails (no optimistic clear)", async () => {
const onSubmit = vi.fn().mockResolvedValue(false);
const { container } = renderCommentInput(onSubmit);
fireEvent.change(screen.getByTestId("editor"), { target: { value: "will fail" } });
fireEvent.click(getSubmitButton(container));
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
// Failed send must NOT clear — the box still has content, submit stays live.
await waitFor(() => expect(getSubmitButton(container)).not.toBeDisabled());
});
});

View File

@@ -1,6 +1,7 @@
"use client";
import { useRef, useState, useCallback, useEffect } from "react";
import { cn } from "@multica/ui/lib/utils";
import { ContentEditor, type ContentEditorRef, useFileDropZone, FileDropOverlay } from "../../editor";
import { FileUploadButton } from "@multica/ui/components/common/file-upload-button";
import { SubmitButton } from "@multica/ui/components/common/submit-button";
@@ -16,7 +17,10 @@ import { useCommentTriggerPreview } from "../hooks/use-comment-trigger-preview";
interface CommentInputProps {
issueId: string;
onSubmit: (content: string, attachmentIds?: string[], suppressAgentIds?: string[]) => Promise<void>;
/** Resolves true on success, false on failure. The composer keeps the text
* (editor locked + button spinning) until this settles, then clears only on
* success — a failed send must not silently discard the user's draft. */
onSubmit: (content: string, attachmentIds?: string[], suppressAgentIds?: string[]) => Promise<boolean>;
}
function CommentInput({ issueId, onSubmit }: CommentInputProps) {
@@ -105,19 +109,26 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
const suppressAgentIds = triggerPreview.agents
.filter((agent) => suppressedAgentIds.has(agent.id))
.map((agent) => agent.id);
// Pessimistic submit: keep the text in place (the editor is locked and the
// button spins via `submitting`) until the server actually accepts it, then
// clear. Clearing only on success means a slow send no longer looks like
// "comment posted but the box is still full", and a failed send keeps the
// draft instead of silently dropping it.
setSubmitting(true);
try {
await onSubmit(
const ok = await onSubmit(
content,
activeIds.length > 0 ? activeIds : undefined,
suppressAgentIds.length > 0 ? suppressAgentIds : undefined,
);
editorRef.current?.clearContent();
setContent("");
setIsEmpty(true);
setSuppressedAgentIds(new Set());
setPendingAttachments([]);
clearDraft(draftKey);
if (ok) {
editorRef.current?.clearContent();
setContent("");
setIsEmpty(true);
setSuppressedAgentIds(new Set());
setPendingAttachments([]);
clearDraft(draftKey);
}
} finally {
setSubmitting(false);
}
@@ -128,7 +139,17 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
{...dropZoneProps}
className="relative flex flex-col rounded-lg bg-card pb-8 ring-1 ring-border"
>
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-2">
{/* Lock the editor while the send is in flight. ContentEditor can't
toggle Tiptap's `editable` post-mount (see its docstring), so the
documented way to make it non-interactive is a pointer-events-none +
dimmed wrapper. */}
<div
className={cn(
"flex-1 min-h-0 overflow-y-auto px-3 py-2",
submitting && "pointer-events-none opacity-60",
)}
aria-busy={submitting || undefined}
>
<ContentEditor
ref={editorRef}
defaultValue={initialDraft}

View File

@@ -26,7 +26,9 @@ interface ReplyInputProps {
placeholder?: string;
avatarType: string;
avatarId: string;
onSubmit: (content: string, attachmentIds?: string[], suppressAgentIds?: string[]) => Promise<void>;
/** Resolves true on success, false on failure — the reply box keeps its text
* (locked + spinning) until then, clearing only on success. */
onSubmit: (content: string, attachmentIds?: string[], suppressAgentIds?: string[]) => Promise<boolean>;
size?: "sm" | "default";
/** When set, hydrates/persists the in-progress reply via the draft store.
* Required for replies inside virtualized timeline threads, where the
@@ -128,19 +130,23 @@ function ReplyInput({
const suppressAgentIds = triggerPreview.agents
.filter((agent) => suppressedAgentIds.has(agent.id))
.map((agent) => agent.id);
// Pessimistic submit (see CommentInput): keep the text, lock + spin, clear
// only once the server accepts it.
setSubmitting(true);
try {
await onSubmit(
const ok = await onSubmit(
content,
activeIds.length > 0 ? activeIds : undefined,
suppressAgentIds.length > 0 ? suppressAgentIds : undefined,
);
editorRef.current?.clearContent();
setContent("");
setIsEmpty(true);
setSuppressedAgentIds(new Set());
setPendingAttachments([]);
if (draftKey) clearDraft(draftKey);
if (ok) {
editorRef.current?.clearContent();
setContent("");
setIsEmpty(true);
setSuppressedAgentIds(new Set());
setPendingAttachments([]);
if (draftKey) clearDraft(draftKey);
}
} finally {
setSubmitting(false);
}
@@ -163,7 +169,14 @@ function ReplyInput({
!isEmpty && "pb-9",
)}
>
<div className="flex-1 min-h-0 overflow-y-auto">
{/* Lock the editor while the reply is in flight — see CommentInput. */}
<div
className={cn(
"flex-1 min-h-0 overflow-y-auto",
submitting && "pointer-events-none opacity-60",
)}
aria-busy={submitting || undefined}
>
<ContentEditor
ref={editorRef}
defaultValue={initialDraft}

View File

@@ -258,25 +258,31 @@ export function useIssueTimeline(issueId: string, userId?: string) {
// --- Mutation functions ---
// Returns true on success, false on failure. The composer keeps the user's
// text (editor locked + button spinning) until this settles and clears only
// on success — so a slow send no longer leaves the box full next to an
// already-posted comment, and a failed send keeps the draft.
const submitComment = useCallback(
async (content: string, attachmentIds?: string[], suppressAgentIds?: string[]) => {
if (!content.trim() || !userId) return;
async (content: string, attachmentIds?: string[], suppressAgentIds?: string[]): Promise<boolean> => {
if (!content.trim() || !userId) return false;
try {
await createComment({ content, attachmentIds, suppressAgentIds });
return true;
} catch (err) {
toast.error(
err instanceof Error && err.message
? err.message
: t(($) => $.comment.send_failed),
);
return false;
}
},
[userId, createComment, t],
);
const submitReply = useCallback(
async (parentId: string, content: string, attachmentIds?: string[], suppressAgentIds?: string[]) => {
if (!content.trim() || !userId) return;
async (parentId: string, content: string, attachmentIds?: string[], suppressAgentIds?: string[]): Promise<boolean> => {
if (!content.trim() || !userId) return false;
try {
await createComment({
content,
@@ -285,12 +291,14 @@ export function useIssueTimeline(issueId: string, userId?: string) {
attachmentIds,
suppressAgentIds,
});
return true;
} catch (err) {
toast.error(
err instanceof Error && err.message
? err.message
: t(($) => $.comment.send_reply_failed),
);
return false;
}
},
[userId, createComment, t],