Compare commits

...

1 Commits

Author SHA1 Message Date
Copilot
67e4789692 fix(create-issue): clean attachment area, multi-select, removable file cards
- Strip uploaded attachment markdown (file-card / image) from the
  persisted draft on close so each new-issue session starts with a
  clean attachment area while preserving typed text (LDN-23 bug 1).
- FileUploadButton: enable native multi-select; fire onSelect once
  per file so existing call sites pick up batch upload for free
  (LDN-23 enhancement 1).
- file-card extension: render a Remove button when the editor is
  editable, so users can drop unwanted attachments before submit
  (LDN-23 enhancement 2). Image already had the equivalent Trash
  button — file cards now match.
- Apply the same close-time strip to the agent quick-create panel,
  whose persisted prompt has the same problem.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-12 17:13:31 +08:00
7 changed files with 109 additions and 17 deletions

View File

@@ -5,11 +5,16 @@ import { Paperclip } from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
interface FileUploadButtonProps {
/** Called with the selected File — caller handles upload. */
/** Called once per selected file — caller handles upload. The native
* picker now allows multi-select by default, so this fires N times for
* N files in a single open. Callers don't need to change anything to
* opt in. Set `multiple={false}` to restore single-file behavior. */
onSelect: (file: File) => void;
disabled?: boolean;
className?: string;
size?: "sm" | "default";
/** Allow multi-select in the native picker. Default true. */
multiple?: boolean;
}
function FileUploadButton({
@@ -17,14 +22,16 @@ function FileUploadButton({
disabled,
className,
size = "default",
multiple = true,
}: FileUploadButtonProps) {
const inputRef = useRef<HTMLInputElement>(null);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const files = e.target.files;
if (!files || files.length === 0) return;
const list = Array.from(files);
e.target.value = "";
onSelect(file);
for (const file of list) onSelect(file);
};
const iconSize = size === "sm" ? "h-3.5 w-3.5" : "h-4 w-4";
@@ -49,6 +56,7 @@ function FileUploadButton({
<input
ref={inputRef}
type="file"
multiple={multiple}
className="hidden"
onChange={handleChange}
/>

View File

@@ -17,7 +17,7 @@
import { Node, mergeAttributes } from "@tiptap/core";
import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react";
import type { NodeViewProps } from "@tiptap/react";
import { FileText, Loader2, Download } from "lucide-react";
import { FileText, Loader2, Download, Trash2 } from "lucide-react";
import { useT } from "../../i18n";
import { useAttachmentDownloadResolver } from "../attachment-download-context";
@@ -30,12 +30,13 @@ import { useAttachmentDownloadResolver } from "../attachment-download-context";
// React NodeView
// ---------------------------------------------------------------------------
function FileCardView({ node }: NodeViewProps) {
function FileCardView({ node, editor, deleteNode }: NodeViewProps) {
const { t } = useT("editor");
const href = (node.attrs.href as string) || "";
const filename = (node.attrs.filename as string) || "";
const uploading = node.attrs.uploading as boolean;
const { openByUrl } = useAttachmentDownloadResolver();
const isEditable = editor.isEditable;
const openFile = () => {
openByUrl(href);
@@ -69,6 +70,21 @@ function FileCardView({ node }: NodeViewProps) {
<Download className="size-3.5" />
</button>
)}
{isEditable && (
<button
type="button"
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title={t(($) => $.file_card.delete)}
aria-label={t(($) => $.file_card.delete)}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
deleteNode();
}}
>
<Trash2 className="size-3.5" />
</button>
)}
</div>
</NodeViewWrapper>
);

View File

@@ -53,7 +53,8 @@
"copy_code": "Copy code"
},
"file_card": {
"uploading": "Uploading {{filename}}"
"uploading": "Uploading {{filename}}",
"delete": "Remove attachment"
},
"title_editor": {
"title_aria_label": "Title"

View File

@@ -53,7 +53,8 @@
"copy_code": "复制代码"
},
"file_card": {
"uploading": "正在上传 {{filename}}"
"uploading": "正在上传 {{filename}}",
"delete": "移除附件"
},
"title_editor": {
"title_aria_label": "标题"

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useRef } from "react";
import { useState, useRef, useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { useNavigation } from "../navigation";
import {
@@ -49,6 +49,34 @@ import { PillButton } from "../common/pill-button";
import { IssuePickerModal } from "./issue-picker-modal";
import { useT } from "../i18n";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Strip embedded attachment markdown nodes (file-card + images) from a
* draft body. Used when the user closes the create-issue modal without
* submitting: the typed text is preserved as a draft, but uploaded
* attachments do NOT carry over to the next "create issue" session.
* This addresses the historical bug where attachment X uploaded into
* Issue A's abandoned draft would still appear when opening Issue B
* with no way to clear it. */
export function stripAttachmentMarkdown(md: string): string {
if (!md) return md;
return md
.split("\n")
.filter((line) => {
const trimmed = line.trim();
// !file[name](url) — custom file-card syntax.
if (/^!file\[[^\]]*\]\(https?:\/\/[^)]+\)\s*$/.test(trimmed)) return false;
// ![alt](url) — image syntax. Lines that are nothing but an image.
if (/^!\[[^\]]*\]\(https?:\/\/[^)]+\)\s*$/.test(trimmed)) return false;
return true;
})
.join("\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
// ---------------------------------------------------------------------------
// ManualCreatePanel — manual-mode body of the create-issue dialog. Renders
// DialogContent + everything inside; the surrounding `<Dialog>` is owned by
@@ -145,6 +173,23 @@ export function ManualCreatePanel({
const createIssueMutation = useCreateIssue();
const updateIssueMutation = useUpdateIssue();
// Tracks whether the panel was unmounted via a successful submit. On a
// plain close (cancel / overlay click) we strip uploaded attachments out
// of the persisted draft so the next "create issue" session starts with
// a clean attachment area while preserving any typed title / body text.
const submittedRef = useRef(false);
useEffect(() => {
return () => {
if (submittedRef.current) return;
const current = useIssueDraftStore.getState().draft.description ?? "";
const cleaned = stripAttachmentMarkdown(current);
if (cleaned !== current) {
useIssueDraftStore.getState().setDraft({ description: cleaned });
}
};
}, []);
const resetForNextIssue = () => {
setTitle("");
setStatus("todo");
@@ -165,6 +210,9 @@ export function ManualCreatePanel({
});
descEditorRef.current?.clearContent();
setFormResetKey((key) => key + 1);
// Re-arm the unmount cleanup: if the user uploads in this fresh
// round and then closes without submitting, strip again.
submittedRef.current = false;
};
const handleSubmit = async () => {
@@ -211,6 +259,7 @@ export function ManualCreatePanel({
setLastAssignee(assigneeType, assigneeId);
setLastMode("manual");
submittedRef.current = true;
clearDraft();
const shouldShowBacklogHint =
status === "backlog" && assigneeType === "agent" && assigneeId &&

View File

@@ -78,8 +78,11 @@ vi.mock("@multica/core/projects/queries", () => ({
}));
vi.mock("@multica/core/issues/stores/quick-create-store", () => ({
useQuickCreateStore: (selector?: (state: typeof mockQuickCreateStore) => unknown) =>
(selector ? selector(mockQuickCreateStore) : mockQuickCreateStore),
useQuickCreateStore: Object.assign(
(selector?: (state: typeof mockQuickCreateStore) => unknown) =>
(selector ? selector(mockQuickCreateStore) : mockQuickCreateStore),
{ getState: () => mockQuickCreateStore },
),
}));
vi.mock("@multica/core/issues/stores/create-mode-store", () => ({

View File

@@ -36,13 +36,9 @@ import { ProjectPicker } from "../projects/components/project-picker";
import { canAssignAgent } from "../issues/components/pickers/assignee-picker";
import { useAuthStore } from "@multica/core/auth";
import { memberListOptions } from "@multica/core/workspace/queries";
import {
ContentEditor,
type ContentEditorRef,
useFileDropZone,
FileDropOverlay,
} from "../editor";
import { ContentEditor, type ContentEditorRef, useFileDropZone, FileDropOverlay } from "../editor";
import { FileUploadButton } from "@multica/ui/components/common/file-upload-button";
import { stripAttachmentMarkdown } from "./create-issue";
import { useT } from "../i18n";
// AgentCreatePanel — agent-mode body of the create-issue dialog. Renders
@@ -210,6 +206,21 @@ export function AgentCreatePanel({
return () => cancelAnimationFrame(id);
}, []);
// On unmount without a successful submit, strip any uploaded attachments
// from the persisted prompt draft so the next "create issue" session
// starts with a clean attachment area while preserving any typed text.
const submittedRef = useRef(false);
useEffect(() => {
return () => {
if (submittedRef.current) return;
const current = useQuickCreateStore.getState().prompt ?? "";
const cleaned = stripAttachmentMarkdown(current);
if (cleaned !== current) {
useQuickCreateStore.getState().setPrompt(cleaned);
}
};
}, []);
const submit = async () => {
const md = editorRef.current?.getMarkdown()?.trim() ?? "";
if (!md || !agentId || submitting || versionBlocked || uploading) return;
@@ -223,6 +234,7 @@ export function AgentCreatePanel({
});
setLastAgentId(agentId);
setLastProjectId(projectId);
submittedRef.current = true;
clearPrompt();
setLastMode("agent");
toast.success(t(($) => $.create_issue.agent.toast_sent), {
@@ -237,6 +249,8 @@ export function AgentCreatePanel({
setJustSent(true);
setTimeout(() => setJustSent(false), 1500);
requestAnimationFrame(() => editorRef.current?.focus());
// Re-arm the unmount cleanup for the next round.
submittedRef.current = false;
} else {
onClose();
}