Compare commits

...

2 Commits

Author SHA1 Message Date
Naiyuan Qing
41e43a302c fix(comments): preserve attachments on submit failure and fix CommentRow indentation
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 10:27:57 +08:00
Naiyuan Qing
0341f04311 fix(comments): clear editor immediately on submit to eliminate WS race visual glitch
The comment editor stayed populated while WebSocket delivered the new
comment faster than the HTTP response, causing a "duplicate comment"
flash. Move clearContent/setIsEmpty before the await so the editor clears
at click time. Also remove dead `submitting` state in useIssueTimeline
(redundant with the input components' own guards) and dead `isTemp` logic
in comment-card (no code path ever creates temp- prefixed entries).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 10:12:59 +08:00
4 changed files with 32 additions and 46 deletions

View File

@@ -265,7 +265,6 @@ function CommentRow({
const isOwn = entry.actor_type === "member" && entry.actor_id === currentUserId;
const canEditEntry = isOwn || (canModerate && entry.actor_type === "member");
const canDeleteEntry = isOwn || canModerate;
const isTemp = entry.id.startsWith("temp-");
const [confirmDelete, setConfirmDelete] = useState(false);
const startEdit = () => {
@@ -325,7 +324,7 @@ function CommentRow({
);
return (
<div className={`py-3${isTemp ? " opacity-60" : ""}`}>
<div className="py-3">
<div className="flex items-center gap-2.5">
<ActorAvatar actorType={entry.actor_type} actorId={entry.actor_id} size={24} enableHoverCard showStatusDot />
<span className="cursor-pointer text-sm font-medium">
@@ -344,12 +343,11 @@ function CommentRow({
</TooltipContent>
</Tooltip>
{!isTemp && (
<div className="ml-auto flex items-center gap-0.5">
<QuickEmojiPicker
onSelect={(emoji) => onToggleReaction(entry.id, emoji)}
align="end"
/>
<div className="ml-auto flex items-center gap-0.5">
<QuickEmojiPicker
onSelect={(emoji) => onToggleReaction(entry.id, emoji)}
align="end"
/>
<DropdownMenu>
<DropdownMenuTrigger
render={
@@ -391,8 +389,7 @@ function CommentRow({
onOpenChange={setConfirmDelete}
onConfirm={() => onDelete(entry.id)}
/>
</div>
)}
</div>
</div>
{editing ? (
@@ -450,16 +447,14 @@ function CommentRow({
<ReadonlyContent content={entry.content ?? ""} attachments={entry.attachments} />
</div>
<AttachmentList attachments={entry.attachments} content={entry.content} className="mt-1.5 pl-8" />
{!isTemp && (
<ReactionBar
reactions={reactions}
currentUserId={currentUserId}
onToggle={(emoji) => onToggleReaction(entry.id, emoji)}
getActorName={getActorName}
hideAddButton={!isLongContent}
className="mt-1.5 pl-8"
/>
)}
<ReactionBar
reactions={reactions}
currentUserId={currentUserId}
onToggle={(emoji) => onToggleReaction(entry.id, emoji)}
getActorName={getActorName}
hideAddButton={!isLongContent}
className="mt-1.5 pl-8"
/>
</>
)}
</div>
@@ -527,7 +522,6 @@ function CommentCardImpl({
// own their own outputs.
const canEditEntry = isOwn || (canModerate && entry.actor_type === "member");
const canDeleteEntry = isOwn || canModerate;
const isTemp = entry.id.startsWith("temp-");
const [confirmDelete, setConfirmDelete] = useState(false);
const startEdit = () => {
@@ -596,7 +590,7 @@ function CommentCardImpl({
const isHighlighted = highlightedCommentId === entry.id;
return (
<Card className={cn("!py-0 !gap-0 overflow-hidden transition-colors duration-700", isTemp && "opacity-60", isHighlighted && "ring-2 ring-brand/50 bg-brand/5")}>
<Card className={cn("!py-0 !gap-0 overflow-hidden transition-colors duration-700", isHighlighted && "ring-2 ring-brand/50 bg-brand/5")}>
{onCollapseResolved && (
<button
type="button"
@@ -646,7 +640,7 @@ function CommentCardImpl({
</span>
)}
{open && !isTemp && (
{open && (
<div className="ml-auto flex items-center gap-0.5">
<QuickEmojiPicker
onSelect={(emoji) => onToggleReaction(entry.id, emoji)}
@@ -776,16 +770,14 @@ function CommentCardImpl({
<ReadonlyContent content={entry.content ?? ""} attachments={entry.attachments} />
</div>
<AttachmentList attachments={entry.attachments} content={entry.content} className="mt-1.5 pl-10" />
{!isTemp && (
<ReactionBar
reactions={reactions}
currentUserId={currentUserId}
onToggle={(emoji) => onToggleReaction(entry.id, emoji)}
getActorName={getActorName}
hideAddButton={!isLongContent}
className="mt-1.5 pl-10"
/>
)}
<ReactionBar
reactions={reactions}
currentUserId={currentUserId}
onToggle={(emoji) => onToggleReaction(entry.id, emoji)}
getActorName={getActorName}
hideAddButton={!isLongContent}
className="mt-1.5 pl-10"
/>
</>
)}
</div>

View File

@@ -77,10 +77,10 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
.filter((a) => content.includes(a.url))
.map((a) => a.id);
setSubmitting(true);
editorRef.current?.clearContent();
setIsEmpty(true);
try {
await onSubmit(content, activeIds.length > 0 ? activeIds : undefined);
editorRef.current?.clearContent();
setIsEmpty(true);
setPendingAttachments([]);
clearDraft(draftKey);
} finally {

View File

@@ -96,10 +96,10 @@ function ReplyInput({
.filter((a) => content.includes(a.url))
.map((a) => a.id);
setSubmitting(true);
editorRef.current?.clearContent();
setIsEmpty(true);
try {
await onSubmit(content, activeIds.length > 0 ? activeIds : undefined);
editorRef.current?.clearContent();
setIsEmpty(true);
setPendingAttachments([]);
if (draftKey) clearDraft(draftKey);
} finally {

View File

@@ -1,6 +1,6 @@
"use client";
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
import { useEffect, useRef, useCallback, useMemo } from "react";
import {
useQuery,
useQueryClient,
@@ -68,8 +68,6 @@ export function useIssueTimeline(issueId: string, userId?: string) {
const timeline = useMemo<TimelineEntry[]>(() => data ?? [], [data]);
const [submitting, setSubmitting] = useState(false);
// Stable mutation handles. TanStack v5 returns a fresh result wrapper from
// useMutation per render, but the inner mutateAsync / mutate functions are
// stable. Pull just those so the useCallback identities downstream don't
@@ -262,8 +260,7 @@ export function useIssueTimeline(issueId: string, userId?: string) {
const submitComment = useCallback(
async (content: string, attachmentIds?: string[]) => {
if (!content.trim() || submitting || !userId) return;
setSubmitting(true);
if (!content.trim() || !userId) return;
try {
await createComment({ content, attachmentIds });
} catch (err) {
@@ -272,11 +269,9 @@ export function useIssueTimeline(issueId: string, userId?: string) {
? err.message
: t(($) => $.comment.send_failed),
);
} finally {
setSubmitting(false);
}
},
[userId, submitting, createComment, t],
[userId, createComment, t],
);
const submitReply = useCallback(
@@ -427,7 +422,6 @@ export function useIssueTimeline(issueId: string, userId?: string) {
return {
timeline: optimisticTimeline,
loading,
submitting,
submitComment,
submitReply,
editComment,