From fa1504186498b004caac3614a7d2a5f9be6fd129 Mon Sep 17 00:00:00 2001 From: Bohan Jiang <52446949+Bohan-J@users.noreply.github.com> Date: Sat, 13 Jun 2026 01:25:08 +0800 Subject: [PATCH] MUL-3254: fix pasted image draft rendering in desktop (#4066) * fix: keep issue draft attachment records Co-authored-by: multica-agent * fix: avoid persisting signed draft attachment urls Co-authored-by: multica-agent * fix: reuse resolved media url for draft previews Co-authored-by: multica-agent * fix: address draft attachment review nits - Backfill an empty caller download_url from the in-session upload on id collision so a just-pasted image first-paints from the signed URL instead of detouring through markdown_url. - Prune draft attachments no longer referenced by the persisted description when the create dialog reopens. - Backfill EMPTY_DRAFT defaults on draft-store rehydrate so drafts persisted before the attachments field existed get a stable shape. Co-authored-by: multica-agent --------- Co-authored-by: J Co-authored-by: multica-agent --- .../core/issues/stores/draft-store.test.ts | 95 ++++++++- packages/core/issues/stores/draft-store.ts | 16 +- .../editor/attachment-preview-modal.test.tsx | 20 ++ .../views/editor/attachment-preview-modal.tsx | 11 +- packages/views/editor/attachment.test.tsx | 33 +++ packages/views/editor/attachment.tsx | 8 +- packages/views/editor/content-editor.test.tsx | 38 ++++ packages/views/editor/content-editor.tsx | 24 ++- packages/views/modals/create-issue.test.tsx | 192 ++++++++++++++++-- packages/views/modals/create-issue.tsx | 54 ++++- 10 files changed, 455 insertions(+), 36 deletions(-) diff --git a/packages/core/issues/stores/draft-store.test.ts b/packages/core/issues/stores/draft-store.test.ts index 0a11121ce..72ea04ace 100644 --- a/packages/core/issues/stores/draft-store.test.ts +++ b/packages/core/issues/stores/draft-store.test.ts @@ -1,5 +1,28 @@ -import { beforeEach, describe, expect, it } from "vitest"; +// @vitest-environment jsdom +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { useIssueDraftStore } from "./draft-store"; +import { setCurrentWorkspace } from "../../platform/workspace-storage"; + +const flush = () => new Promise((resolve) => queueMicrotask(() => resolve(null))); + +// Node 25 ships a partial `localStorage` shim under jsdom that's missing +// `clear`/`removeItem`; replace it with a real in-memory Storage so persist +// can round-trip values. +beforeAll(() => { + if (typeof globalThis.localStorage?.clear !== "function") { + const values = new Map(); + const storage: Storage = { + get length() { return values.size; }, + clear: () => values.clear(), + getItem: (k) => values.get(k) ?? null, + key: (i) => Array.from(values.keys())[i] ?? null, + removeItem: (k) => { values.delete(k); }, + setItem: (k, v) => { values.set(k, v); }, + }; + Object.defineProperty(globalThis, "localStorage", { configurable: true, value: storage }); + Object.defineProperty(window, "localStorage", { configurable: true, value: storage }); + } +}); const RESET_STATE = { draft: { @@ -11,6 +34,7 @@ const RESET_STATE = { assigneeId: undefined, startDate: null, dueDate: null, + attachments: [], }, lastAssigneeType: undefined, lastAssigneeId: undefined, @@ -46,6 +70,36 @@ describe("issue draft store — last assignee", () => { expect(draft.assigneeId).toBeUndefined(); }); + it("clearDraft removes persisted draft attachments", () => { + const { setDraft, clearDraft } = useIssueDraftStore.getState(); + + setDraft({ + title: "first", + attachments: [ + { + id: "11111111-2222-3333-4444-555555555555", + workspace_id: "ws-1", + issue_id: null, + comment_id: null, + chat_session_id: null, + chat_message_id: null, + uploader_type: "member", + uploader_id: "alice", + filename: "shot.png", + url: "https://cdn.example.test/shot.png", + download_url: "https://cdn.example.test/shot.png", + markdown_url: "https://app.example.test/api/attachments/11111111-2222-3333-4444-555555555555/download", + content_type: "image/png", + size_bytes: 123, + created_at: "2026-06-12T00:00:00Z", + }, + ], + }); + clearDraft(); + + expect(useIssueDraftStore.getState().draft.attachments).toEqual([]); + }); + it("setLastAssignee(undefined) lets the user opt back out of a default", () => { const { setLastAssignee, clearDraft } = useIssueDraftStore.getState(); @@ -59,3 +113,42 @@ describe("issue draft store — last assignee", () => { expect(useIssueDraftStore.getState().draft.assigneeType).toBeUndefined(); }); }); + +describe("issue draft store — legacy rehydrate", () => { + beforeEach(() => { + localStorage.clear(); + setCurrentWorkspace(null, null); + }); + + afterEach(() => { + setCurrentWorkspace(null, null); + }); + + it("backfills attachments for drafts persisted before the field existed", async () => { + localStorage.setItem( + "multica_issue_draft:acme", + JSON.stringify({ + state: { + draft: { + title: "legacy", + description: "body", + status: "todo", + priority: "none", + startDate: null, + dueDate: null, + // no `attachments` — written by a build that predates the field + }, + }, + version: 0, + }), + ); + + setCurrentWorkspace("acme", "ws_a"); + await flush(); + await flush(); + + const { draft } = useIssueDraftStore.getState(); + expect(draft.title).toBe("legacy"); + expect(draft.attachments).toEqual([]); + }); +}); diff --git a/packages/core/issues/stores/draft-store.ts b/packages/core/issues/stores/draft-store.ts index 6c284ee3d..379d19634 100644 --- a/packages/core/issues/stores/draft-store.ts +++ b/packages/core/issues/stores/draft-store.ts @@ -1,6 +1,6 @@ import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; -import type { IssueStatus, IssuePriority, IssueAssigneeType } from "../../types"; +import type { IssueStatus, IssuePriority, IssueAssigneeType, Attachment } from "../../types"; import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage"; import { defaultStorage } from "../../platform/storage"; @@ -13,6 +13,7 @@ interface IssueDraft { assigneeId?: string; startDate: string | null; dueDate: string | null; + attachments: Attachment[]; } const EMPTY_DRAFT: IssueDraft = { @@ -24,6 +25,7 @@ const EMPTY_DRAFT: IssueDraft = { assigneeId: undefined, startDate: null, dueDate: null, + attachments: [], }; interface IssueDraftStore { @@ -65,6 +67,18 @@ export const useIssueDraftStore = create()( { name: "multica_issue_draft", storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)), + // Drafts persisted by older builds predate fields added later (e.g. + // `attachments`). Backfill EMPTY_DRAFT defaults on rehydrate so every + // read site can rely on the declared IssueDraft shape instead of + // re-defending with `?? fallback`. + merge: (persistedState, currentState) => { + const persisted = (persistedState ?? {}) as Partial; + return { + ...currentState, + ...persisted, + draft: { ...EMPTY_DRAFT, ...persisted.draft }, + }; + }, }, ), ); diff --git a/packages/views/editor/attachment-preview-modal.test.tsx b/packages/views/editor/attachment-preview-modal.test.tsx index 9e628a273..a3b7b7a26 100644 --- a/packages/views/editor/attachment-preview-modal.test.tsx +++ b/packages/views/editor/attachment-preview-modal.test.tsx @@ -174,6 +174,26 @@ describe("AttachmentPreviewModal — dispatch", () => { expect(img?.getAttribute("alt")).toBe(att.filename); }); + it("falls back to durable media URLs when a full attachment has no download_url", () => { + const att = makeAttachment({ + filename: "shot.png", + content_type: "image/png", + download_url: "", + markdown_url: "https://api.example.test/api/attachments/att-1/download", + url: "https://cdn.example.test/att-1.png?Signature=old", + }); + render( + {}} + />, + ); + const img = document.querySelector("img"); + expect(img?.getAttribute("src")).toBe(att.markdown_url); + expect(img?.getAttribute("src")).not.toContain("Signature="); + }); + it("renders an from a URL-only source for image filenames", () => { const url = "https://cdn.example.test/orphan.png?Signature=s"; render( diff --git a/packages/views/editor/attachment-preview-modal.tsx b/packages/views/editor/attachment-preview-modal.tsx index f304e4bbf..cb57dd536 100644 --- a/packages/views/editor/attachment-preview-modal.tsx +++ b/packages/views/editor/attachment-preview-modal.tsx @@ -92,6 +92,12 @@ interface PreviewState { attachmentId: string | null; } +function resolvePreviewMediaUrl(attachment: Attachment): string { + const raw = + attachment.download_url || attachment.markdown_url || attachment.url; + return resolvePublicFileUrl(raw) ?? raw; +} + function normalize(source: PreviewSource): PreviewState { // Resolve any server-relative URL (e.g. `/api/attachments/{id}/download` // returned by the unified-endpoint metadata path when no CloudFront @@ -102,13 +108,10 @@ function normalize(source: PreviewSource): PreviewState { // form so `` / `