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 ( +