From 817296dc4e961cccf620bd024986a7f93df41702 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Tue, 14 Jul 2026 01:16:36 +0800 Subject: [PATCH] fix(issues): close out Howard review on blocker 3 draft externalization (MUL-4475) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - edit composer (comment-card `useEditAttachmentState`): externalize pendingAttachments + suppressedAgentIds into the edit draft and re-hydrate them in startEdit, so uploading an attachment mid-edit → tab-switch remount → save keeps the attachment id (was dropped). Hook exported for testing. - top-level + reply composers: persist attachments/suppressed only INTO an existing content draft (never fabricate a content-only empty draft), and always write the current value — incl. `[]` — so un-suppressing an agent no longer leaves a stale array that re-suppresses it on remount. - issue-surface: import `getIssueSurfaceViewStore` from the public `@multica/core/issues/stores` barrel instead of the deep private path. New regression coverage: edit attachment survives remount → save keeps id; suppress → un-suppress persists `[]` and a remount stays un-suppressed; toggling suppression with no content never creates an empty draft. Verify: views typecheck clean; full views suite 1862 pass; eslint clean. Co-Authored-By: Claude Opus 4.8 Co-authored-by: multica-agent --- .../views/issues/components/comment-card.tsx | 23 +- .../comment-draft-persistence.test.tsx | 256 ++++++++++++++++++ .../views/issues/components/comment-input.tsx | 15 +- .../views/issues/components/reply-input.tsx | 17 +- .../views/issues/surface/issue-surface.tsx | 8 +- 5 files changed, 300 insertions(+), 19 deletions(-) create mode 100644 packages/views/issues/components/comment-draft-persistence.test.tsx diff --git a/packages/views/issues/components/comment-card.tsx b/packages/views/issues/components/comment-card.tsx index 53d07d2971..33a0e5908a 100644 --- a/packages/views/issues/components/comment-card.tsx +++ b/packages/views/issues/components/comment-card.tsx @@ -309,7 +309,7 @@ function TaskCommentRetryButton({ // Shared edit-attachment state hook // --------------------------------------------------------------------------- -function useEditAttachmentState( +export function useEditAttachmentState( issueId: string, entry: TimelineEntry, onEdit: (commentId: string, content: string, attachmentIds: string[], suppressAgentIds?: string[]) => Promise, @@ -390,9 +390,28 @@ function useEditAttachmentState( clearDraft(draftKey); }; + // Persist edit attachments + suppressed choices into the edit draft so a + // remount (desktop tab switch / virtualization) can restore them via + // startEdit — only into an existing content draft, always writing the + // current value (incl. `[]`). See CommentInput for the rationale. + useEffect(() => { + if (!editing) return; + if (useCommentDraftStore.getState().getDraft(draftKey) === undefined) return; + setDraft(draftKey, { attachments: pendingAttachments }); + }, [editing, pendingAttachments, draftKey, setDraft]); + + useEffect(() => { + if (!editing) return; + if (useCommentDraftStore.getState().getDraft(draftKey) === undefined) return; + setDraft(draftKey, { suppressedAgentIds: [...suppressedAgentIds] }); + }, [editing, suppressedAgentIds, draftKey, setDraft]); + const startEdit = () => { cancelledRef.current = false; - setContent(getDraft(draftKey)?.content ?? entry.content ?? ""); + const draft = getDraft(draftKey); + setContent(draft?.content ?? entry.content ?? ""); + setPendingAttachments(draft?.attachments ?? []); + setSuppressedAgentIds(new Set(draft?.suppressedAgentIds ?? [])); setRetainedStandaloneIds(initialStandaloneAttachmentIds(entry)); setEditing(true); }; diff --git a/packages/views/issues/components/comment-draft-persistence.test.tsx b/packages/views/issues/components/comment-draft-persistence.test.tsx new file mode 100644 index 0000000000..9b69714bd4 --- /dev/null +++ b/packages/views/issues/components/comment-draft-persistence.test.tsx @@ -0,0 +1,256 @@ +import { + forwardRef, + useImperativeHandle, + useRef, + act, + type Ref, +} from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import type { Attachment, TimelineEntry } from "@multica/core/types"; +import { useCommentDraftStore } from "@multica/core/issues/stores"; +import { CommentInput } from "./comment-input"; +import { useEditAttachmentState } from "./comment-card"; + +// One controllable trigger agent so a suppression chip renders. Hoisted + +// stable so the composer's "reconcile suppressed against visible agents" effect +// runs once and toggling doesn't churn it. +const mockAgents = vi.hoisted(() => [{ id: "ag-1", name: "Walt", source: "mention_agent" }]); + +vi.mock("../hooks/use-comment-trigger-preview", () => ({ + useCommentTriggerPreview: () => ({ agents: mockAgents }), +})); + +// Test double for the chip strip: one toggle button per agent. +vi.mock("./comment-trigger-chips", () => ({ + CommentTriggerChips: ({ + agents, + suppressedAgentIds, + onToggle, + }: { + agents: { id: string; name: string }[]; + suppressedAgentIds: Set; + onToggle: (id: string) => void; + }) => ( +
+ {agents.map((a) => ( + + ))} +
+ ), +})); + +vi.mock("@multica/core/api", () => ({ api: {} })); + +vi.mock("@multica/core/hooks/use-file-upload", () => ({ + useFileUpload: () => ({ uploadWithToast: vi.fn() }), +})); + +vi.mock("../../i18n", () => ({ + useT: () => ({ t: () => "translated" }), + useTimeAgo: () => () => "now", +})); + +vi.mock("../../editor", () => ({ + useFileDropZone: () => ({ + isDragOver: false, + dropZoneProps: { "data-testid": "drop-zone" }, + }), + FileDropOverlay: () => null, + ContentEditor: forwardRef(function MockContentEditor( + { + defaultValue, + onUpdate, + }: { + defaultValue?: string; + onUpdate?: (markdown: string) => void; + }, + ref: Ref, + ) { + const valueRef = useRef(defaultValue ?? ""); + useImperativeHandle(ref, () => ({ + getMarkdown: () => valueRef.current, + clearContent: () => { + valueRef.current = ""; + }, + focus: () => {}, + blur: () => {}, + uploadFile: async () => {}, + hasActiveUploads: () => false, + })); + return ( +