From bdc8e27bf4173533b3d58d90c0f4816849a688cd Mon Sep 17 00:00:00 2001 From: dotta Date: Sat, 4 Apr 2026 19:10:58 -0500 Subject: [PATCH 01/33] Fix mention popup placement and spaced queries Co-Authored-By: Paperclip --- ui/src/components/MarkdownEditor.test.tsx | 29 ++++++- ui/src/components/MarkdownEditor.tsx | 94 ++++++++++++++++------- 2 files changed, 93 insertions(+), 30 deletions(-) diff --git a/ui/src/components/MarkdownEditor.test.tsx b/ui/src/components/MarkdownEditor.test.tsx index 0df20323..a2b18728 100644 --- a/ui/src/components/MarkdownEditor.test.tsx +++ b/ui/src/components/MarkdownEditor.test.tsx @@ -3,7 +3,7 @@ import { act } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { computeMentionMenuPosition, MarkdownEditor } from "./MarkdownEditor"; +import { computeMentionMenuPosition, findMentionMatch, MarkdownEditor } from "./MarkdownEditor"; const mdxEditorMockState = vi.hoisted(() => ({ emitMountEmptyReset: false, @@ -186,4 +186,31 @@ describe("MarkdownEditor", () => { left: 92, }); }); + + it("keeps a short mention menu on the same line when it fits below the caret", () => { + expect( + computeMentionMenuPosition( + { viewportTop: 160, viewportLeft: 120 }, + { offsetLeft: 0, offsetTop: 0, width: 320, height: 220 }, + { width: 188, height: 42 }, + ), + ).toEqual({ + top: 164, + left: 120, + }); + }); + + it("keeps mention queries active across spaces", () => { + expect(findMentionMatch("Ping @Paperclip App", "Ping @Paperclip App".length)).toEqual({ + trigger: "mention", + marker: "@", + query: "Paperclip App", + atPos: 5, + endPos: "Ping @Paperclip App".length, + }); + }); + + it("still rejects slash commands once spaces are typed", () => { + expect(findMentionMatch("/open issue", "/open issue".length)).toBeNull(); + }); }); diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index d528d493..62f4010d 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -108,9 +108,16 @@ interface MentionMenuViewport { height: number; } +interface MentionMenuSize { + width: number; + height: number; +} + const MENTION_MENU_WIDTH = 188; const MENTION_MENU_HEIGHT = 208; const MENTION_MENU_PADDING = 8; +const MENTION_MENU_ROW_HEIGHT = 34; +const MENTION_MENU_CHROME_HEIGHT = 8; const CODE_BLOCK_LANGUAGES: Record = { txt: "Text", @@ -140,19 +147,10 @@ const FALLBACK_CODE_BLOCK_DESCRIPTOR: CodeBlockEditorDescriptor = { Editor: CodeMirrorEditor, }; -function detectMention(container: HTMLElement): MentionState | null { - const sel = window.getSelection(); - if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) return null; - - const range = sel.getRangeAt(0); - const textNode = range.startContainer; - if (textNode.nodeType !== Node.TEXT_NODE) return null; - if (!container.contains(textNode)) return null; - - const text = textNode.textContent ?? ""; - const offset = range.startOffset; - - // Walk backwards from cursor to find an autocomplete trigger. +export function findMentionMatch( + text: string, + offset: number, +): Pick | null { let atPos = -1; let trigger: MentionState["trigger"] | null = null; let marker: MentionState["marker"] | null = null; @@ -166,31 +164,54 @@ function detectMention(container: HTMLElement): MentionState | null { } break; } - if (/\s/.test(ch)) break; + if (ch === "\n" || ch === "\r") break; } if (atPos === -1) return null; - const query = text.slice(atPos + 1, offset); - - // Get position relative to container - const tempRange = document.createRange(); - tempRange.setStart(textNode, atPos); - tempRange.setEnd(textNode, atPos + 1); - const rect = tempRange.getBoundingClientRect(); - const containerRect = container.getBoundingClientRect(); + if (trigger === "skill" && /\s/.test(query)) return null; return { trigger: trigger ?? "mention", marker: marker ?? "@", query, + atPos, + endPos: offset, + }; +} + +function detectMention(container: HTMLElement): MentionState | null { + const sel = window.getSelection(); + if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) return null; + + const range = sel.getRangeAt(0); + const textNode = range.startContainer; + if (textNode.nodeType !== Node.TEXT_NODE) return null; + if (!container.contains(textNode)) return null; + + const text = textNode.textContent ?? ""; + const offset = range.startOffset; + const match = findMentionMatch(text, offset); + if (!match) return null; + + // Get position relative to container + const tempRange = document.createRange(); + tempRange.setStart(textNode, match.atPos); + tempRange.setEnd(textNode, match.atPos + 1); + const rect = tempRange.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + + return { + trigger: match.trigger, + marker: match.marker, + query: match.query, top: rect.bottom - containerRect.top, left: rect.left - containerRect.left, viewportTop: rect.bottom, viewportLeft: rect.left, textNode: textNode as Text, - atPos, - endPos: offset, + atPos: match.atPos, + endPos: match.endPos, }; } @@ -216,11 +237,12 @@ function getMentionMenuViewport(): MentionMenuViewport { export function computeMentionMenuPosition( anchor: Pick, viewport: MentionMenuViewport, + menuSize: MentionMenuSize = { width: MENTION_MENU_WIDTH, height: MENTION_MENU_HEIGHT }, ) { const minLeft = viewport.offsetLeft + MENTION_MENU_PADDING; - const maxLeft = viewport.offsetLeft + viewport.width - MENTION_MENU_WIDTH; + const maxLeft = viewport.offsetLeft + viewport.width - menuSize.width; const minTop = viewport.offsetTop + MENTION_MENU_PADDING; - const maxTop = viewport.offsetTop + viewport.height - MENTION_MENU_HEIGHT; + const maxTop = viewport.offsetTop + viewport.height - menuSize.height; return { top: Math.max(minTop, Math.min(viewport.offsetTop + anchor.viewportTop + 4, maxTop)), @@ -228,6 +250,17 @@ export function computeMentionMenuPosition( }; } +function getMentionMenuSize(optionCount: number): MentionMenuSize { + const visibleRows = Math.max(1, Math.min(optionCount, 8)); + return { + width: MENTION_MENU_WIDTH, + height: Math.min( + MENTION_MENU_HEIGHT, + visibleRows * MENTION_MENU_ROW_HEIGHT + MENTION_MENU_CHROME_HEIGHT, + ), + }; +} + function nodeInsideCodeLike(container: HTMLElement, node: Node | null): boolean { if (!node || !container.contains(node)) return false; const el = node.nodeType === Node.ELEMENT_NODE @@ -650,7 +683,11 @@ export const MarkdownEditor = forwardRef }, []); const mentionMenuPosition = mentionState - ? computeMentionMenuPosition(mentionState, getMentionMenuViewport()) + ? computeMentionMenuPosition( + mentionState, + getMentionMenuViewport(), + getMentionMenuSize(filteredMentions.length), + ) : null; return ( @@ -673,8 +710,7 @@ export const MarkdownEditor = forwardRef // Mention keyboard handling if (mentionActive) { - // Space dismisses the popup (let the character be typed normally) - if (e.key === " ") { + if (e.key === " " && mentionStateRef.current?.trigger === "skill") { mentionStateRef.current = null; setMentionState(null); return; From 517fe5093e883784163164fd1c45b765a8de61fb Mon Sep 17 00:00:00 2001 From: dotta Date: Sat, 4 Apr 2026 19:57:25 -0500 Subject: [PATCH 02/33] Fix inbox archive flashing back after fade-out The archive mutation was only using CSS opacity to hide items while the network request was in flight. When the query refetch completed or the archiving timer expired, the item could reappear. Now we optimistically remove the item from React Query caches on mutate, snapshot previous data for rollback on error, and sync with the server in onSettled. Co-Authored-By: Paperclip --- ui/src/pages/Inbox.tsx | 49 +++++++++++++++++++++++++++++++----------- 1 file changed, 36 insertions(+), 13 deletions(-) diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index b0898f40..45594db7 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -1245,30 +1245,53 @@ export function Inbox() { const archiveIssueMutation = useMutation({ mutationFn: (id: string) => issuesApi.archiveFromInbox(id), - onMutate: (id) => { + onMutate: async (id) => { setActionError(null); setArchivingIssueIds((prev) => new Set(prev).add(id)); + + // Cancel in-flight refetches so they don't overwrite our optimistic update + const queryKeys_ = [ + queryKeys.issues.listMineByMe(selectedCompanyId!), + queryKeys.issues.listTouchedByMe(selectedCompanyId!), + queryKeys.issues.listUnreadTouchedByMe(selectedCompanyId!), + ]; + await Promise.all(queryKeys_.map((qk) => queryClient.cancelQueries({ queryKey: qk }))); + + // Snapshot previous data for rollback + const previousData = queryKeys_.map((qk) => [qk, queryClient.getQueryData(qk)] as const); + + // Optimistically remove the issue from all inbox query caches + for (const qk of queryKeys_) { + queryClient.setQueryData(qk, (old: unknown) => { + if (!Array.isArray(old)) return old; + return old.filter((issue: { id: string }) => issue.id !== id); + }); + } + + return { previousData }; }, - onSuccess: () => { - invalidateInboxIssueQueries(); - }, - onError: (err, id) => { + onError: (err, id, context) => { setActionError(err instanceof Error ? err.message : "Failed to archive issue"); setArchivingIssueIds((prev) => { const next = new Set(prev); next.delete(id); return next; }); + // Restore previous query data on failure + if (context?.previousData) { + for (const [qk, data] of context.previousData) { + queryClient.setQueryData(qk, data); + } + } }, onSettled: (_data, error, id) => { - if (error) return; - window.setTimeout(() => { - setArchivingIssueIds((prev) => { - const next = new Set(prev); - next.delete(id); - return next; - }); - }, 500); + // Clean up archiving state and refetch to sync with server + setArchivingIssueIds((prev) => { + const next = new Set(prev); + next.delete(id); + return next; + }); + invalidateInboxIssueQueries(); }, }); From e9c8bd48058c42a06d4317b1e2ed00c30907ea7c Mon Sep 17 00:00:00 2001 From: dotta Date: Sun, 5 Apr 2026 06:29:33 -0500 Subject: [PATCH 03/33] Allow arbitrary issue attachments Co-Authored-By: Paperclip --- server/src/__tests__/attachment-types.test.ts | 32 +++- .../__tests__/issue-attachment-routes.test.ts | 175 ++++++++++++++++++ server/src/attachment-types.ts | 24 ++- server/src/routes/issues.ts | 23 ++- ui/src/pages/IssueDetail.tsx | 1 - 5 files changed, 242 insertions(+), 13 deletions(-) create mode 100644 server/src/__tests__/issue-attachment-routes.test.ts diff --git a/server/src/__tests__/attachment-types.test.ts b/server/src/__tests__/attachment-types.test.ts index 5a430102..7dfc34d2 100644 --- a/server/src/__tests__/attachment-types.test.ts +++ b/server/src/__tests__/attachment-types.test.ts @@ -1,8 +1,11 @@ import { describe, it, expect } from "vitest"; import { - parseAllowedTypes, - matchesContentType, DEFAULT_ALLOWED_TYPES, + INLINE_ATTACHMENT_TYPES, + isInlineAttachmentContentType, + matchesContentType, + normalizeContentType, + parseAllowedTypes, } from "../attachment-types.js"; describe("parseAllowedTypes", () => { @@ -95,3 +98,28 @@ describe("matchesContentType", () => { expect(matchesContentType("application/zip", patterns)).toBe(true); }); }); + +describe("normalizeContentType", () => { + it("lowercases and trims explicit types", () => { + expect(normalizeContentType(" Application/Zip ")).toBe("application/zip"); + }); + + it("falls back to octet-stream when the type is missing", () => { + expect(normalizeContentType(undefined)).toBe("application/octet-stream"); + expect(normalizeContentType("")).toBe("application/octet-stream"); + }); +}); + +describe("isInlineAttachmentContentType", () => { + it("allows the configured inline-safe types", () => { + for (const contentType of ["image/png", "image/svg+xml", "application/pdf", "text/plain"]) { + expect(isInlineAttachmentContentType(contentType)).toBe(true); + } + }); + + it("rejects potentially unsafe or binary download types", () => { + expect(INLINE_ATTACHMENT_TYPES).not.toContain("text/html"); + expect(isInlineAttachmentContentType("text/html")).toBe(false); + expect(isInlineAttachmentContentType("application/zip")).toBe(false); + }); +}); diff --git a/server/src/__tests__/issue-attachment-routes.test.ts b/server/src/__tests__/issue-attachment-routes.test.ts new file mode 100644 index 00000000..42a6b4e6 --- /dev/null +++ b/server/src/__tests__/issue-attachment-routes.test.ts @@ -0,0 +1,175 @@ +import { Readable } from "node:stream"; +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { errorHandler } from "../middleware/index.js"; +import { issueRoutes } from "../routes/issues.js"; +import type { StorageService } from "../storage/types.js"; + +const mockIssueService = vi.hoisted(() => ({ + getById: vi.fn(), + getByIdentifier: vi.fn(), + createAttachment: vi.fn(), + getAttachmentById: vi.fn(), +})); + +const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined)); + +vi.mock("../services/index.js", () => ({ + accessService: () => ({ + canUser: vi.fn(), + hasPermission: vi.fn(), + }), + agentService: () => ({ + getById: vi.fn(), + }), + documentService: () => ({}), + executionWorkspaceService: () => ({}), + feedbackService: () => ({ + listIssueVotesForUser: vi.fn(async () => []), + saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })), + }), + goalService: () => ({}), + heartbeatService: () => ({ + wakeup: vi.fn(async () => undefined), + reportRunActivity: vi.fn(async () => undefined), + getRun: vi.fn(async () => null), + getActiveRunForAgent: vi.fn(async () => null), + cancelRun: vi.fn(async () => null), + }), + instanceSettingsService: () => ({ + get: vi.fn(async () => ({ + id: "instance-settings-1", + general: { + censorUsernameInLogs: false, + feedbackDataSharingPreference: "prompt", + }, + })), + listCompanyIds: vi.fn(async () => ["company-1"]), + }), + issueApprovalService: () => ({}), + issueService: () => mockIssueService, + logActivity: mockLogActivity, + projectService: () => ({}), + routineService: () => ({ + syncRunStatusForIssue: vi.fn(async () => undefined), + }), + workProductService: () => ({}), +})); + +function createStorageService(): StorageService { + return { + provider: "local_disk", + putFile: vi.fn(async (input) => ({ + provider: "local_disk", + objectKey: `${input.namespace}/${input.originalFilename ?? "upload"}`, + contentType: input.contentType, + byteSize: input.body.length, + sha256: "sha256-sample", + originalFilename: input.originalFilename, + })), + getObject: vi.fn(async () => ({ + stream: Readable.from(Buffer.from("test")), + contentLength: 4, + })), + headObject: vi.fn(), + deleteObject: vi.fn(), + }; +} + +function createApp(storage: StorageService) { + const app = express(); + app.use((req, _res, next) => { + (req as any).actor = { + type: "board", + userId: "local-board", + companyIds: ["company-1"], + source: "local_implicit", + isInstanceAdmin: false, + }; + next(); + }); + app.use("/api", issueRoutes({} as any, storage)); + app.use(errorHandler); + return app; +} + +function makeAttachment(contentType: string, originalFilename: string) { + const now = new Date("2026-01-01T00:00:00.000Z"); + return { + id: "attachment-1", + companyId: "company-1", + issueId: "11111111-1111-4111-8111-111111111111", + issueCommentId: null, + assetId: "asset-1", + provider: "local_disk", + objectKey: `issues/issue-1/${originalFilename}`, + contentType, + byteSize: 4, + sha256: "sha256-sample", + originalFilename, + createdByAgentId: null, + createdByUserId: "local-board", + createdAt: now, + updatedAt: now, + }; +} + +describe("issue attachment routes", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("accepts zip uploads for issue attachments", async () => { + const storage = createStorageService(); + mockIssueService.getById.mockResolvedValue({ + id: "11111111-1111-4111-8111-111111111111", + companyId: "company-1", + identifier: "PAP-1", + }); + mockIssueService.createAttachment.mockResolvedValue(makeAttachment("application/zip", "bundle.zip")); + + const res = await request(createApp(storage)) + .post("/api/companies/company-1/issues/11111111-1111-4111-8111-111111111111/attachments") + .attach("file", Buffer.from("zip"), { filename: "bundle.zip", contentType: "application/zip" }); + + expect(res.status).toBe(201); + const putFileCall = vi.mocked(storage.putFile).mock.calls[0]?.[0]; + expect(putFileCall).toMatchObject({ + companyId: "company-1", + namespace: "issues/11111111-1111-4111-8111-111111111111", + originalFilename: "bundle.zip", + contentType: "application/zip", + }); + expect(Buffer.isBuffer(putFileCall?.body)).toBe(true); + expect(mockIssueService.createAttachment).toHaveBeenCalledWith( + expect.objectContaining({ + issueId: "11111111-1111-4111-8111-111111111111", + contentType: "application/zip", + originalFilename: "bundle.zip", + }), + ); + expect(res.body.contentType).toBe("application/zip"); + }); + + it("serves html attachments as downloads with nosniff", async () => { + const storage = createStorageService(); + mockIssueService.getAttachmentById.mockResolvedValue(makeAttachment("text/html", "report.html")); + + const res = await request(createApp(storage)).get("/api/attachments/attachment-1/content"); + + expect(res.status).toBe(200); + expect(res.headers["content-disposition"]).toBe('attachment; filename="report.html"'); + expect(res.headers["x-content-type-options"]).toBe("nosniff"); + }); + + it("keeps image attachments inline for previews", async () => { + const storage = createStorageService(); + mockIssueService.getAttachmentById.mockResolvedValue(makeAttachment("image/png", "preview.png")); + + const res = await request(createApp(storage)).get("/api/attachments/attachment-1/content"); + + expect(res.status).toBe(200); + expect(res.headers["content-disposition"]).toBe('inline; filename="preview.png"'); + }); +}); diff --git a/server/src/attachment-types.ts b/server/src/attachment-types.ts index b9349179..9c02b86d 100644 --- a/server/src/attachment-types.ts +++ b/server/src/attachment-types.ts @@ -1,10 +1,10 @@ /** * Shared attachment content-type configuration. * - * By default only image types are allowed. Set the + * By default a curated set of image/document/text types are allowed. Set the * `PAPERCLIP_ALLOWED_ATTACHMENT_TYPES` environment variable to a * comma-separated list of MIME types or wildcard patterns to expand the - * allowed set. + * allowed set for routes that use this allowlist. * * Examples: * PAPERCLIP_ALLOWED_ATTACHMENT_TYPES=image/*,application/pdf @@ -29,6 +29,17 @@ export const DEFAULT_ALLOWED_TYPES: readonly string[] = [ "text/html", ]; +export const DEFAULT_ATTACHMENT_CONTENT_TYPE = "application/octet-stream"; +export const SVG_CONTENT_TYPE = "image/svg+xml"; +export const INLINE_ATTACHMENT_TYPES: readonly string[] = [ + "image/*", + "application/pdf", + "text/plain", + "text/markdown", + "application/json", + "text/csv", +]; + /** * Parse a comma-separated list of MIME type patterns into a normalised array. * Returns the default image-only list when the input is empty or undefined. @@ -59,6 +70,15 @@ export function matchesContentType(contentType: string, allowedPatterns: string[ }); } +export function normalizeContentType(contentType: string | null | undefined): string { + const normalized = (contentType ?? "").trim().toLowerCase(); + return normalized || DEFAULT_ATTACHMENT_CONTENT_TYPE; +} + +export function isInlineAttachmentContentType(contentType: string): boolean { + return matchesContentType(contentType, [...INLINE_ATTACHMENT_TYPES]); +} + // ---------- Module-level singletons read once at startup ---------- const allowedPatterns: string[] = parseAllowedTypes( diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 4617b7ed..9dfe26db 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -47,7 +47,12 @@ import { logger } from "../middleware/logger.js"; import { forbidden, HttpError, unauthorized } from "../errors.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js"; -import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.js"; +import { + isInlineAttachmentContentType, + MAX_ATTACHMENT_BYTES, + normalizeContentType, + SVG_CONTENT_TYPE, +} from "../attachment-types.js"; import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup.js"; const MAX_ISSUE_COMMENT_LIMIT = 500; @@ -2108,11 +2113,7 @@ export function issueRoutes( res.status(400).json({ error: "Missing file field 'file'" }); return; } - const contentType = (file.mimetype || "").toLowerCase(); - if (!isAllowedContentType(contentType)) { - res.status(422).json({ error: `Unsupported attachment type: ${contentType || "unknown"}` }); - return; - } + const contentType = normalizeContentType(file.mimetype); if (file.buffer.length <= 0) { res.status(422).json({ error: "Attachment is empty" }); return; @@ -2176,11 +2177,17 @@ export function issueRoutes( assertCompanyAccess(req, attachment.companyId); const object = await storage.getObject(attachment.companyId, attachment.objectKey); - res.setHeader("Content-Type", attachment.contentType || object.contentType || "application/octet-stream"); + const responseContentType = normalizeContentType(attachment.contentType || object.contentType); + res.setHeader("Content-Type", responseContentType); res.setHeader("Content-Length", String(attachment.byteSize || object.contentLength || 0)); res.setHeader("Cache-Control", "private, max-age=60"); + res.setHeader("X-Content-Type-Options", "nosniff"); + if (responseContentType === SVG_CONTENT_TYPE) { + res.setHeader("Content-Security-Policy", "sandbox; default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'"); + } const filename = attachment.originalFilename ?? "attachment"; - res.setHeader("Content-Disposition", `inline; filename=\"${filename.replaceAll("\"", "")}\"`); + const disposition = isInlineAttachmentContentType(responseContentType) ? "inline" : "attachment"; + res.setHeader("Content-Disposition", `${disposition}; filename=\"${filename.replaceAll("\"", "")}\"`); object.stream.on("error", (err) => { next(err); diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index d16a3103..1ab5b578 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -1121,7 +1121,6 @@ export function IssueDetail() { Date: Sun, 5 Apr 2026 06:39:20 -0500 Subject: [PATCH 04/33] Support dropping non-image files onto markdown editor as attachments When dragging files like .zip onto the issue description editor, non-image files are now uploaded as attachments instead of being silently ignored. Images continue to be handled inline by MDXEditor's image plugin. Co-Authored-By: Paperclip --- ui/src/components/InlineEditor.tsx | 4 ++++ ui/src/components/MarkdownEditor.tsx | 34 +++++++++++++++++++++++----- ui/src/pages/IssueDetail.tsx | 3 +++ 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/ui/src/components/InlineEditor.tsx b/ui/src/components/InlineEditor.tsx index 8b4c5b27..f509fe7a 100644 --- a/ui/src/components/InlineEditor.tsx +++ b/ui/src/components/InlineEditor.tsx @@ -11,6 +11,8 @@ interface InlineEditorProps { placeholder?: string; multiline?: boolean; imageUploadHandler?: (file: File) => Promise; + /** Called when a non-image file is dropped onto the editor. */ + onDropFile?: (file: File) => Promise; mentions?: MentionOption[]; nullable?: boolean; } @@ -46,6 +48,7 @@ export function InlineEditor({ multiline = false, nullable = false, imageUploadHandler, + onDropFile, mentions, }: InlineEditorProps) { const [editing, setEditing] = useState(false); @@ -228,6 +231,7 @@ export function InlineEditor({ className="bg-transparent" contentClassName={cn("paperclip-edit-in-place-content", className)} imageUploadHandler={imageUploadHandler} + onDropFile={onDropFile} mentions={mentions} onSubmit={() => { finalizeMultilineBlurOrSubmit(); diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index 62f4010d..ff779926 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -62,6 +62,8 @@ interface MarkdownEditorProps { contentClassName?: string; onBlur?: () => void; imageUploadHandler?: (file: File) => Promise; + /** Called when a non-image file is dropped onto the editor (e.g. .zip). */ + onDropFile?: (file: File) => Promise; bordered?: boolean; /** List of mentionable entities. Enables @-mention autocomplete. */ mentions?: MentionOption[]; @@ -314,6 +316,7 @@ export const MarkdownEditor = forwardRef contentClassName, onBlur, imageUploadHandler, + onDropFile, bordered = true, mentions, onSubmit, @@ -668,6 +671,7 @@ export const MarkdownEditor = forwardRef } const canDropImage = Boolean(imageUploadHandler); + const canDropFile = Boolean(imageUploadHandler || onDropFile); const handlePasteCapture = useCallback((event: ClipboardEvent) => { const clipboard = event.clipboardData; if (!clipboard || !ref.current) return; @@ -747,23 +751,41 @@ export const MarkdownEditor = forwardRef } }} onDragEnter={(evt) => { - if (!canDropImage || !hasFilePayload(evt)) return; + if (!canDropFile || !hasFilePayload(evt)) return; dragDepthRef.current += 1; setIsDragOver(true); }} onDragOver={(evt) => { - if (!canDropImage || !hasFilePayload(evt)) return; + if (!canDropFile || !hasFilePayload(evt)) return; evt.preventDefault(); evt.dataTransfer.dropEffect = "copy"; }} onDragLeave={() => { - if (!canDropImage) return; + if (!canDropFile) return; dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); if (dragDepthRef.current === 0) setIsDragOver(false); }} - onDrop={() => { + onDrop={(evt) => { dragDepthRef.current = 0; setIsDragOver(false); + if (!onDropFile) return; + const files = evt.dataTransfer?.files; + if (!files || files.length === 0) return; + const allFiles = Array.from(files); + const nonImageFiles = allFiles.filter( + (f) => !f.type.startsWith("image/"), + ); + if (nonImageFiles.length === 0) return; + // If all dropped files are non-image, prevent default so MDXEditor + // doesn't try to handle them. If mixed, let images flow through to + // the image plugin and only handle the non-image files ourselves. + if (nonImageFiles.length === allFiles.length) { + evt.preventDefault(); + evt.stopPropagation(); + } + for (const file of nonImageFiles) { + void onDropFile(file); + } }} onPasteCapture={handlePasteCapture} > @@ -854,14 +876,14 @@ export const MarkdownEditor = forwardRef document.body, )} - {isDragOver && canDropImage && ( + {isDragOver && canDropFile && (
- Drop image to upload + Drop file to upload
)} {uploadError && ( diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 1ab5b578..d2cf091f 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -1329,6 +1329,9 @@ export function IssueDetail() { const attachment = await uploadAttachment.mutateAsync(file); return attachment.contentPath; }} + onDropFile={async (file) => { + await uploadAttachment.mutateAsync(file); + }} /> From 612bab1eb65242c81c7cace673b6bbd57b6df247 Mon Sep 17 00:00:00 2001 From: dotta Date: Sun, 5 Apr 2026 07:35:33 -0500 Subject: [PATCH 05/33] Make execution workspace detail page responsive for mobile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reduce card padding on small screens (p-4 → p-4 sm:p-5) - Reduce spacing between sections on mobile (space-y-4 sm:space-y-6) - Scale heading text (text-xl sm:text-2xl) - Truncate long description on mobile, show full on sm+ - Reduce textarea min-heights on mobile (sm: prefix for larger sizes) - Stack linked issue cards vertically on mobile, horizontal scroll on sm+ - Remove min-width constraint on linked issue cards on mobile Co-Authored-By: Paperclip --- ui/src/pages/ExecutionWorkspaceDetail.tsx | 39 ++++++++++++----------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/ui/src/pages/ExecutionWorkspaceDetail.tsx b/ui/src/pages/ExecutionWorkspaceDetail.tsx index 7fdac3e9..a031ed84 100644 --- a/ui/src/pages/ExecutionWorkspaceDetail.tsx +++ b/ui/src/pages/ExecutionWorkspaceDetail.tsx @@ -378,7 +378,7 @@ export function ExecutionWorkspaceDetail() { return ( <> -
+
-
-
-
+
+
+
Execution workspace
-

{workspace.name}

+

{workspace.name}

- Configure the concrete runtime workspace that Paperclip reuses for this issue flow. These settings stay + Configure the concrete runtime workspace that Paperclip reuses for this issue flow. + These settings stay attached to the execution workspace so future runs can keep local paths, repo refs, provisioning, teardown, - and runtime-service behavior in sync with the actual workspace being reused. + and runtime-service behavior in sync with the actual workspace being reused.

@@ -482,7 +483,7 @@ export function ExecutionWorkspaceDetail() {