From 16dfcb56a4b684f9ceb9f714cfe49e177bc9a1aa Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 14 Mar 2026 07:13:59 -0500 Subject: [PATCH] feat(ui): stage issue files before create Co-Authored-By: Paperclip --- ui/src/components/NewIssueDialog.tsx | 313 +++++++++++++++++++++++++-- 1 file changed, 293 insertions(+), 20 deletions(-) diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index c017306c..6797bd0b 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useCallback, useMemo, type ChangeEvent } from "react"; +import { useState, useEffect, useRef, useCallback, useMemo, type ChangeEvent, type DragEvent } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useDialog } from "../context/DialogContext"; import { useCompany } from "../context/CompanyContext"; @@ -10,6 +10,7 @@ import { assetsApi } from "../api/assets"; import { queryKeys } from "../lib/queryKeys"; import { useProjectOrder } from "../hooks/useProjectOrder"; import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees"; +import { useToast } from "../context/ToastContext"; import { assigneeValueFromSelection, currentUserAssigneeOption, @@ -39,7 +40,9 @@ import { Tag, Calendar, Paperclip, + FileText, Loader2, + X, } from "lucide-react"; import { cn } from "../lib/utils"; import { extractProviderIdWithFallback } from "../lib/model-utils"; @@ -77,7 +80,16 @@ interface IssueDraft { useIsolatedExecutionWorkspace: boolean; } +type StagedIssueFile = { + id: string; + file: File; + kind: "document" | "attachment"; + documentKey?: string; + title?: string | null; +}; + const ISSUE_OVERRIDE_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "opencode_local"]); +const STAGED_FILE_ACCEPT = "image/*,application/pdf,text/plain,text/markdown,application/json,text/csv,text/html,.md,.markdown"; const ISSUE_THINKING_EFFORT_OPTIONS = { claude_local: [ @@ -156,6 +168,57 @@ function clearDraft() { localStorage.removeItem(DRAFT_KEY); } +function isMarkdownFile(file: File) { + const name = file.name.toLowerCase(); + return ( + name.endsWith(".md") || + name.endsWith(".markdown") || + file.type === "text/markdown" + ); +} + +function fileBaseName(filename: string) { + return filename.replace(/\.[^.]+$/, ""); +} + +function slugifyDocumentKey(input: string) { + const slug = input + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return slug || "document"; +} + +function titleizeFilename(input: string) { + return input + .split(/[-_ ]+/g) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +function createUniqueDocumentKey(baseKey: string, stagedFiles: StagedIssueFile[]) { + const existingKeys = new Set( + stagedFiles + .filter((file) => file.kind === "document") + .map((file) => file.documentKey) + .filter((key): key is string => Boolean(key)), + ); + if (!existingKeys.has(baseKey)) return baseKey; + let suffix = 2; + while (existingKeys.has(`${baseKey}-${suffix}`)) { + suffix += 1; + } + return `${baseKey}-${suffix}`; +} + +function formatFileSize(file: File) { + if (file.size < 1024) return `${file.size} B`; + if (file.size < 1024 * 1024) return `${(file.size / 1024).toFixed(1)} KB`; + return `${(file.size / (1024 * 1024)).toFixed(1)} MB`; +} + const statuses = [ { value: "backlog", label: "Backlog", color: issueStatusText.backlog ?? issueStatusTextDefault }, { value: "todo", label: "Todo", color: issueStatusText.todo ?? issueStatusTextDefault }, @@ -175,6 +238,7 @@ export function NewIssueDialog() { const { newIssueOpen, newIssueDefaults, closeNewIssue } = useDialog(); const { companies, selectedCompanyId, selectedCompany } = useCompany(); const queryClient = useQueryClient(); + const { pushToast } = useToast(); const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); const [status, setStatus] = useState("todo"); @@ -188,6 +252,8 @@ export function NewIssueDialog() { const [useIsolatedExecutionWorkspace, setUseIsolatedExecutionWorkspace] = useState(false); const [expanded, setExpanded] = useState(false); const [dialogCompanyId, setDialogCompanyId] = useState(null); + const [stagedFiles, setStagedFiles] = useState([]); + const [isFileDragOver, setIsFileDragOver] = useState(false); const draftTimer = useRef | null>(null); const executionWorkspaceDefaultProjectId = useRef(null); @@ -201,6 +267,7 @@ export function NewIssueDialog() { const [companyOpen, setCompanyOpen] = useState(false); const descriptionEditorRef = useRef(null); const attachInputRef = useRef(null); + const stageFileInputRef = useRef(null); const assigneeSelectorRef = useRef(null); const projectSelectorRef = useRef(null); @@ -268,11 +335,49 @@ export function NewIssueDialog() { }); const createIssue = useMutation({ - mutationFn: ({ companyId, ...data }: { companyId: string } & Record) => - issuesApi.create(companyId, data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(effectiveCompanyId!) }); + mutationFn: async ({ + companyId, + stagedFiles: pendingStagedFiles, + ...data + }: { companyId: string; stagedFiles: StagedIssueFile[] } & Record) => { + const issue = await issuesApi.create(companyId, data); + const failures: string[] = []; + + for (const stagedFile of pendingStagedFiles) { + try { + if (stagedFile.kind === "document") { + const body = await stagedFile.file.text(); + await issuesApi.upsertDocument(issue.id, stagedFile.documentKey ?? "document", { + title: stagedFile.documentKey === "plan" ? null : stagedFile.title ?? null, + format: "markdown", + body, + baseRevisionId: null, + }); + } else { + await issuesApi.uploadAttachment(companyId, issue.id, stagedFile.file); + } + } catch { + failures.push(stagedFile.file.name); + } + } + + return { issue, companyId, failures }; + }, + onSuccess: ({ issue, companyId, failures }) => { + queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) }); if (draftTimer.current) clearTimeout(draftTimer.current); + if (failures.length > 0) { + const prefix = (companies.find((company) => company.id === companyId)?.issuePrefix ?? "").trim(); + const issueRef = issue.identifier ?? issue.id; + pushToast({ + title: `Created ${issueRef} with upload warnings`, + body: `${failures.length} staged ${failures.length === 1 ? "file" : "files"} could not be added.`, + tone: "warn", + action: prefix + ? { label: `Open ${issueRef}`, href: `/${prefix}/issues/${issueRef}` } + : undefined, + }); + } clearDraft(); reset(); closeNewIssue(); @@ -413,6 +518,8 @@ export function NewIssueDialog() { setUseIsolatedExecutionWorkspace(false); setExpanded(false); setDialogCompanyId(null); + setStagedFiles([]); + setIsFileDragOver(false); setCompanyOpen(false); executionWorkspaceDefaultProjectId.current = null; } @@ -453,6 +560,7 @@ export function NewIssueDialog() { : null; createIssue.mutate({ companyId: effectiveCompanyId, + stagedFiles, title: title.trim(), description: description.trim() || undefined, status, @@ -487,7 +595,70 @@ export function NewIssueDialog() { } } - const hasDraft = title.trim().length > 0 || description.trim().length > 0; + function stageFiles(files: File[]) { + if (files.length === 0) return; + setStagedFiles((current) => { + const next = [...current]; + for (const file of files) { + if (isMarkdownFile(file)) { + const baseName = fileBaseName(file.name); + const documentKey = createUniqueDocumentKey(slugifyDocumentKey(baseName), next); + next.push({ + id: `${file.name}:${file.size}:${file.lastModified}:${documentKey}`, + file, + kind: "document", + documentKey, + title: titleizeFilename(baseName), + }); + continue; + } + next.push({ + id: `${file.name}:${file.size}:${file.lastModified}`, + file, + kind: "attachment", + }); + } + return next; + }); + } + + function handleStageFilesPicked(evt: ChangeEvent) { + stageFiles(Array.from(evt.target.files ?? [])); + if (stageFileInputRef.current) { + stageFileInputRef.current.value = ""; + } + } + + function handleFileDragEnter(evt: DragEvent) { + if (!evt.dataTransfer.types.includes("Files")) return; + evt.preventDefault(); + setIsFileDragOver(true); + } + + function handleFileDragOver(evt: DragEvent) { + if (!evt.dataTransfer.types.includes("Files")) return; + evt.preventDefault(); + evt.dataTransfer.dropEffect = "copy"; + setIsFileDragOver(true); + } + + function handleFileDragLeave(evt: DragEvent) { + if (evt.currentTarget.contains(evt.relatedTarget as Node | null)) return; + setIsFileDragOver(false); + } + + function handleFileDrop(evt: DragEvent) { + if (!evt.dataTransfer.files.length) return; + evt.preventDefault(); + setIsFileDragOver(false); + stageFiles(Array.from(evt.dataTransfer.files)); + } + + function removeStagedFile(id: string) { + setStagedFiles((current) => current.filter((file) => file.id !== id)); + } + + const hasDraft = title.trim().length > 0 || description.trim().length > 0 || stagedFiles.length > 0; const currentStatus = statuses.find((s) => s.value === status) ?? statuses[1]!; const currentPriority = priorities.find((p) => p.value === priority); const currentAssignee = selectedAssigneeAgentId @@ -541,6 +712,8 @@ export function NewIssueDialog() { const canDiscardDraft = hasDraft || hasSavedDraft; const createIssueErrorMessage = createIssue.error instanceof Error ? createIssue.error.message : "Failed to create issue. Try again."; + const stagedDocuments = stagedFiles.filter((file) => file.kind === "document"); + const stagedAttachments = stagedFiles.filter((file) => file.kind === "attachment"); const handleProjectChange = useCallback((nextProjectId: string) => { setProjectId(nextProjectId); @@ -938,20 +1111,103 @@ export function NewIssueDialog() { )} {/* Description */} -
- { - const asset = await uploadDescriptionImage.mutateAsync(file); - return asset.contentPath; - }} - /> +
+
+ { + const asset = await uploadDescriptionImage.mutateAsync(file); + return asset.contentPath; + }} + /> +
+ {stagedFiles.length > 0 ? ( +
+ {stagedDocuments.length > 0 ? ( +
+
Documents
+
+ {stagedDocuments.map((file) => ( +
+
+
+ + {file.documentKey} + + {file.file.name} +
+
+ + {file.title || file.file.name} + + {formatFileSize(file.file)} +
+
+ +
+ ))} +
+
+ ) : null} + + {stagedAttachments.length > 0 ? ( +
+
Attachments
+
+ {stagedAttachments.map((file) => ( +
+
+
+ + {file.file.name} +
+
+ {file.file.type || "application/octet-stream"} • {formatFileSize(file.file)} +
+
+ +
+ ))} +
+
+ ) : null} +
+ ) : null}
{/* Property chips bar */} @@ -1038,6 +1294,23 @@ export function NewIssueDialog() { {uploadDescriptionImage.isPending ? "Uploading..." : "Image"} + + + {/* More (dates) */}