mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-22 15:09:22 +02:00
Compare commits
1 Commits
feat/react
...
fix/compos
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79dc8cfc6e |
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user