diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 106e8412cf1..96426bf35b9 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -4024,6 +4024,11 @@ under its original key; it is never relabelled as a provider refusal. Legacy unowned form drafts remain in browser storage but are not restored into another account. Storybook fixtures seed the same account-specific keys. +New-task staged file bytes are stored in IndexedDB for the account and company. +Writes merge the selected files with the current saved list in one read/write +transaction. Removal and successful creation delete only the files that the +mounted draft removed. Other open tabs keep their saved files across reloads. + Successful pending-record writes have the same readable size range as browser storage. In particular, a large task description cannot silently drop its creation key on reload. Reselected text documents recover their original diff --git a/maps/feature-map/tasks.md b/maps/feature-map/tasks.md index 66fa4b4b99a..fa735bb3d07 100644 --- a/maps/feature-map/tasks.md +++ b/maps/feature-map/tasks.md @@ -53,6 +53,14 @@ the personal workspace preference. Verify explicit history expansion afterward. ### `new-task` Use New task from the global launcher, task list, or project task view. +Images selected, pasted, or dropped into either the new-task or thread composer +appear as equal-size rounded thumbnails above the text. Check mixed image/file +selections, removal, thread upload failure/retry, and restored thread drafts. +New-task files persist in account- and company-scoped browser storage across +reloads and are cleared after creation. Verify that restored image bytes reach +the task and that older inline-image drafts keep the rich editor. +The Storybook new-task and add-menu suites have desktop and mobile image examples; +their upload and submit boundaries are fixtures. ### `task-list` diff --git a/tests/e2e/board-attachment-receipts.spec.ts b/tests/e2e/board-attachment-receipts.spec.ts index a26baf2acf6..c408aeb60b4 100644 --- a/tests/e2e/board-attachment-receipts.spec.ts +++ b/tests/e2e/board-attachment-receipts.spec.ts @@ -125,6 +125,108 @@ async function openAttachmentChooser(page: Page, composer: Locator) { return chooser; } +test("new-task images survive reload, retain bytes, and clear after creation", async ({ page, request }) => { + const fixture = await setup(page, request, false); + await page.getByRole("button", { name: "New Task", exact: true }).first().click(); + const dialog = page.getByRole("dialog", { name: "New task", exact: true }); + const editor = dialog.getByRole("textbox", { name: "editable markdown", exact: true }); + await editor.fill("Keep the caption and image after reload."); + const chooser = await openAttachmentChooser(page, dialog); + await chooser.setFiles(files[1]!); + await expect(dialog.getByRole("img", { name: "board-fresh.png", exact: true })).toBeVisible(); + await expect.poll(() => page.evaluate(async () => { + const db = await new Promise((resolve, reject) => { + const request = indexedDB.open("paperclip:issue-draft-files", 1); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + try { + return await new Promise((resolve, reject) => { + const tx = db.transaction("files", "readonly"); + const request = tx.objectStore("files").getAll(); + tx.oncomplete = () => resolve(request.result.flat().length); + tx.onerror = () => reject(tx.error); + }); + } finally { db.close(); } + })).toBe(1); + await expect.poll(() => page.evaluate(() => Object.entries(localStorage) + .some(([key, value]) => key.startsWith("paperclip:issue-draft:") && value.includes("Keep the caption")))).toBe(true); + await page.reload(); + await page.getByRole("button", { name: "New Task", exact: true }).first().click(); + await expect(editor).toContainText("Keep the caption and image after reload."); + await expect(dialog.getByRole("img", { name: "board-fresh.png", exact: true })).toBeVisible(); + const upload = page.waitForResponse((response) => response.request().method() === "POST" + && /\/issues\/[^/]+\/attachments$/.test(new URL(response.url()).pathname)); + await dialog.getByRole("button", { name: "Create task", exact: true }).click(); + const attachment = await body(await upload); + const content = await request.get(attachment.contentPath); + expect(content.ok()).toBe(true); + expect(await content.body()).toEqual(files[1]!.buffer); + await expect(dialog).toBeHidden(); + await page.reload(); + await page.getByRole("button", { name: "New Task", exact: true }).first().click(); + await expect(dialog.getByRole("img")).toHaveCount(0); +}); + +test("an older image draft retains the rich editor", async ({ page, request }) => { + const fixture = await setup(page, request, false); + const attachment = await body(await request.post(`/api/companies/${fixture.company.id}/issues/${fixture.issue.id}/attachments`, { + multipart: { file: { name: files[1]!.name, mimeType: files[1]!.mimeType, buffer: files[1]!.buffer } }, + })); + await page.addInitScript(({ issueId, imagePath }) => { + localStorage.setItem(`paperclip:issue-comment-draft:${issueId}`, `Keep this older caption.\n\n![Older image](${imagePath})`); + }, { issueId: fixture.issue.id, imagePath: attachment.contentPath }); + await page.reload(); + await expect(fixture.editor).toBeVisible(); + await expect(fixture.editor).toContainText("Keep this older caption."); + await expect(fixture.composer.getByRole("img", { name: "Older image", exact: true })).toBeVisible(); + await expect(fixture.composer.locator("textarea")).toHaveCount(0); +}); + +test("two open tabs retain their staged images when either tab saves or creates a task", async ({ page, request, context }) => { + await setup(page, request, false); + const second = await context.newPage(); + await second.goto(page.url()); + const dialogs = [page, second].map((tab) => tab.getByRole("dialog", { name: "New task", exact: true })); + for (const [index, tab] of [page, second].entries()) { + await tab.getByRole("button", { name: "New Task", exact: true }).first().click(); + await dialogs[index]!.getByRole("textbox", { name: "editable markdown", exact: true }).fill(`Keep tab ${index + 1}'s image.`); + await expect(dialogs[index]!.getByRole("button", { name: "Create task", exact: true })).toBeEnabled(); + } + const savedNames = () => page.evaluate(async () => { + const db = await new Promise((resolve, reject) => { + const request = indexedDB.open("paperclip:issue-draft-files", 1); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + try { + return await new Promise((resolve, reject) => { + const tx = db.transaction("files", "readonly"); + const request = tx.objectStore("files").getAll(); + tx.oncomplete = () => resolve(request.result.flat().map((item: { file: File }) => item.file.name).sort()); + tx.onerror = () => reject(tx.error); + }); + } finally { db.close(); } + }); + for (const [index, tab] of [page, second].entries()) { + const chooser = await openAttachmentChooser(tab, dialogs[index]!); + await chooser.setFiles({ ...files[1]!, name: `tab-${index + 1}.png` }); + await expect(dialogs[index]!.getByRole("img", { name: `tab-${index + 1}.png`, exact: true })).toBeVisible(); + await expect.poll(savedNames).toEqual(index === 0 ? ["tab-1.png"] : ["tab-1.png", "tab-2.png"]); + } + await dialogs[0]!.getByRole("button", { name: "Create task", exact: true }).click(); + await expect(dialogs[0]!).toBeHidden(); + await expect.poll(savedNames).toEqual(["tab-2.png"]); + await second.reload(); + await second.getByRole("button", { name: "New Task", exact: true }).first().click(); + await expect(dialogs[1]!.getByRole("img", { name: "tab-2.png", exact: true })).toBeVisible(); + await dialogs[1]!.getByRole("textbox", { name: "editable markdown", exact: true }).fill("Create the remaining tab's image."); + await dialogs[1]!.getByRole("button", { name: "Create task", exact: true }).click(); + await expect(dialogs[1]!).toBeHidden(); + await expect.poll(savedNames).toEqual([]); + await second.close(); +}); + async function upload( page: Page, fixture: Awaited>, @@ -410,7 +512,7 @@ for (const classic of [false, true]) try { await expect( fixture.composer.getByText( - classic ? "Uploading to task" : "Uploading…", + classic ? "Uploading to task" : "Uploading board-fresh.png…", { exact: true }, ), ).toBeVisible(); diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index 39a0f3d9f6b..2d1521a3864 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -945,8 +945,6 @@ export const MarkdownEditor = forwardRef }; }, [editorValue, placeholder, richEditorError]); - // Whether the image plugin should be included (boolean is stable across renders - // as long as the handler presence doesn't toggle) const hasImageUpload = Boolean(imageUploadHandler); const plugins = useMemo(() => { @@ -1003,11 +1001,7 @@ export const MarkdownEditor = forwardRef codeMirrorPlugin({ codeBlockLanguages: CODE_BLOCK_LANGUAGES }), markdownShortcutPlugin(), ]; - if (imageHandler) { - // The inline image chip keeps only its remove affordance — no settings - // dialog, and the X glyph instead of MDXEditor's default trash can. - all.push(imagePlugin({ imageUploadHandler: imageHandler, disableImageSettingsButton: true })); - } + all.push(imagePlugin({ imageUploadHandler: imageHandler, disableImageSettingsButton: true })); return all; }, [hasImageUpload]); diff --git a/ui/src/components/NewIssueDialog.test.tsx b/ui/src/components/NewIssueDialog.test.tsx index b04fb244969..d5f9a259b63 100644 --- a/ui/src/components/NewIssueDialog.test.tsx +++ b/ui/src/components/NewIssueDialog.test.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom import type { ComponentProps, ReactNode } from "react"; +import { act as reactAct } from "react"; import { webcrypto } from "node:crypto"; import { flushSync } from "react-dom"; import { createRoot } from "react-dom/client"; @@ -13,6 +14,13 @@ import { rememberComposerEffort } from "../lib/recent-composer-effort"; import { addStagedFilesToIssue, NewIssueDialog } from "./NewIssueDialog"; const primaryState = vi.hoisted(() => ({ primaryAgentId: null as string | null, loading: false })); +const storedFiles = vi.hoisted(() => new Map()); +vi.mock("../lib/staged-issue-files", () => ({ + readStagedIssueFiles: vi.fn(async (key: string) => storedFiles.get(key) ?? []), + writeStagedIssueFiles: vi.fn(async (key: string, files: import("../lib/staged-issue-files").StagedIssueFile[]) => { + storedFiles.set(key, files); + }), +})); vi.mock("./primary-agent/PrimaryAgentPresentation", () => ({ usePrimaryAgentPresentation: () => primaryState })); const coordinatorState = vi.hoisted(() => ({ enabled: false })); vi.mock("../hooks/useCoordinatorWorkspaceEnabled", () => ({ useCoordinatorWorkspaceEnabled: () => coordinatorState })); @@ -291,7 +299,7 @@ function act(callback: () => void | Promise): void | Promise { } async function flush() { - await act(async () => { + await reactAct(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); } @@ -402,6 +410,7 @@ describe("NewIssueDialog", () => { mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false }); mockMissingUserSecretsBannerRender.mockReset(); localStorage.clear(); + storedFiles.clear(); mockIssuesApi.create.mockResolvedValue({ id: "issue-2", companyId: "company-1", @@ -1840,6 +1849,57 @@ describe("NewIssueDialog", () => { } finally { storage?.mockRestore(); act(() => root.unmount()); queryClient.clear(); } }); + it.each(["image/png", "", "application/octet-stream"])("shows staged images above text with MIME '%s' and uploads only retained files", async (type) => { + dialogState.newIssueDefaults = { title: "Review image references" }; + mockIssuesApi.create.mockResolvedValue({ id: "image-task", identifier: "PAP-1", companyId: "company-1" }); + mockIssuesApi.uploadAttachment.mockResolvedValue({}); + const { root, queryClient } = renderDialog(container); + try { + await flush(); + const editor = container.querySelector('textarea[aria-label="Describe a task…"]')!; + await typeTextareaValue(editor, "Keep this caption"); + const retained = new File(["image"], "reference.png", { type }); + const removed = new File(["other"], "removed.png", { type: "image/png" }); + const input = container.querySelector('input[type="file"]')!; + Object.defineProperty(input, "files", { configurable: true, value: [retained, removed] }); + await act(async () => { input.dispatchEvent(new Event("change", { bubbles: true })); }); + await flush(); + const row = container.querySelector('[data-testid="task-chat-composer-images"]')!; + expect(row.querySelectorAll("img")).toHaveLength(2); + expect(row.compareDocumentPosition(editor) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(editor.value).toBe("Keep this caption"); + await act(async () => { container.querySelector('[aria-label="Remove removed.png"]')!.click(); }); + await act(async () => { container.querySelector('[aria-label="Create task"]')!.click(); }); + await waitForAssertion(() => expect(mockIssuesApi.uploadAttachment).toHaveBeenCalledTimes(1)); + expect(mockIssuesApi.uploadAttachment).toHaveBeenCalledWith("company-1", "image-task", retained); + expect(mockIssuesApi.create.mock.calls[0][1].description).toBe("Keep this caption"); + expect(mockAssetsApi.uploadImage).not.toHaveBeenCalled(); + } finally { act(() => root.unmount()); queryClient.clear(); } + }); + + it("restores staged image bytes after remount and clears them after creation", async () => { + dialogState.newIssueDefaults = { title: "Retain these images" }; + const file = new File(["image bytes"], "reference.png", { type: "image/png", lastModified: 12 }); + const first = renderDialog(container); + await flush(); + await waitForAssertion(() => expect(container.querySelector('[aria-label="Create task"]')!.disabled).toBe(false)); + const input = container.querySelector('input[type="file"]')!; + Object.defineProperty(input, "files", { configurable: true, value: [file] }); + await act(async () => { input.dispatchEvent(new Event("change", { bubbles: true })); }); + await flush(); + expect(storedFiles.get(JSON.stringify(["user-1", "company-1"]))?.[0].file).toBe(file); + act(() => first.root.unmount()); + first.queryClient.clear(); + const second = renderDialog(container); + try { + await flush(); + await waitForAssertion(() => expect(container.querySelector('img[alt="reference.png"]')).not.toBeNull()); + await act(async () => { container.querySelector('[aria-label="Create task"]')!.click(); }); + await waitForAssertion(() => expect(mockIssuesApi.uploadAttachment).toHaveBeenCalledWith("company-1", "issue-2", file)); + await waitForAssertion(() => expect(storedFiles.get(JSON.stringify(["user-1", "company-1"])) ?? []).toEqual([])); + } finally { act(() => second.root.unmount()); second.queryClient.clear(); } + }); + it.each(["create", "upload"] as const)("stops old staged work after an account change during %s", async (during) => { dialogState.newIssueDefaults = { title: "Account A in-flight task" }; let finish!: (value: unknown) => void; diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index d38ff1b7f30..434c704ccd0 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -13,7 +13,6 @@ import { projectsApi } from "../api/projects"; import { agentsApi } from "../api/agents"; import { accessApi } from "../api/access"; import { authApi } from "../api/auth"; -import { assetsApi } from "../api/assets"; import { buildCompanyUserInlineOptions, buildMarkdownMentionOptions, isAgentTaskTarget } from "../lib/company-members"; import { queryKeys } from "../lib/queryKeys"; import { useNavigate } from "../lib/router"; @@ -51,14 +50,17 @@ import { clearPendingSend, pendingSendKeyFor, readPendingSend, writePendingSend, import { useDraftOwner, useDraftOwnerReader } from "../hooks/useDraftOwner"; import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector"; import { getTrustPreset } from "../lib/trust-policy-ui"; +import { useStagedIssueFiles } from "../hooks/useStagedIssueFiles"; +import type { StagedIssueFile } from "../lib/staged-issue-files"; +import { isImageAttachment } from "./task-chat/task-chat-attachments"; const DRAFT_KEY = "paperclip:issue-draft"; /** A create that is waiting to resend: its request body and idempotency key. */ const PENDING_CREATE_KEY = "paperclip:issue-draft:pending-create"; /** - * The create request. Staged files cannot be stored, so they ride in memory and - * only their names are saved: a create restored after reload knows it needs them. + * The create request keeps file names for reconciliation. File bytes are stored + * separately in the account- and company-scoped browser draft. */ type PendingCreate = { fingerprint: string; data: Record; stagedFileNames?: string[] }; @@ -203,14 +205,6 @@ interface IssueDraft { composerSettings?: ComposerRunSettings | null; } -type StagedIssueFile = { - id: string; - file: File; - kind: "document" | "attachment"; - documentKey?: string; - title?: string | null; -}; - /** * Files a keyed create has already added to its task. A replayed create (same * key, after a reload mid-upload) gets the existing task back from the @@ -491,7 +485,6 @@ function AccountNewIssueDialog({ ignoreDefaults }: { ignoreDefaults: boolean }) const [isPrivate, setIsPrivate] = useState(false); const { isMobile } = useSidebar(); const [composerSettings, setComposerSettings] = useState(null); - const [stagedFiles, setStagedFiles] = useState([]); const [isFileDragOver, setIsFileDragOver] = useState(false); const draftTimer = useRef | null>(null); const executionWorkspaceDefaultProjectId = useRef(null); @@ -502,6 +495,7 @@ function AccountNewIssueDialog({ ignoreDefaults }: { ignoreDefaults: boolean }) // sent or cancelled, whatever the files and fields look like when resubmitted. const createRequestRef = useRef<{ fingerprint: string; idempotencyKey: string; recovering?: boolean; payload?: PendingCreate } | null>(null); const draftOwner = useDraftOwner(); + const { files: stagedFiles, setFiles: setStagedFiles, restoring: filesRestoring, error: filesStorageError } = useStagedIssueFiles(draftOwner, effectiveCompanyId); const [restoredLaunch, setRestoredLaunch] = useState<{ owner: string | undefined; parentId?: string; goalId?: string } | null>(null); const savedLaunch = restoredLaunch?.owner === draftOwner ? restoredLaunch : null; const parentId = savedLaunch ? savedLaunch.parentId : newIssueDefaults.parentId; @@ -1063,7 +1057,7 @@ function AccountNewIssueDialog({ ignoreDefaults }: { ignoreDefaults: boolean }) async function handleSubmit(body: string, mode: IssueWorkMode, settings: ComposerRunSettings | null) { const currentTitle = titleRef.current.trim(); const currentDescription = body.trim(); - if (!effectiveCompanyId || (!currentTitle && !currentDescription) || createIssue.isPending || durableCreate.pending || worktreeSelectionIncomplete || parentPrivacyUnresolved) return; + if (!effectiveCompanyId || filesRestoring || (!currentTitle && !currentDescription) || createIssue.isPending || durableCreate.pending || worktreeSelectionIncomplete || parentPrivacyUnresolved) return; const inheritedOverrides = buildAssigneeAdapterOverrides({ adapterType: assigneeAdapterType, lane: assigneeChrome ? "custom" : assigneeModelLane, @@ -1200,7 +1194,7 @@ function AccountNewIssueDialog({ ignoreDefaults }: { ignoreDefaults: boolean }) } function stageFiles(files: File[]) { - if (files.length === 0 || createIssue.isPending || durableCreate.pending) return; + if (files.length === 0 || filesRestoring || createIssue.isPending || durableCreate.pending) return; setStagedFiles((current) => { const next = [...current]; for (const file of files) { @@ -1329,7 +1323,8 @@ function AccountNewIssueDialog({ ignoreDefaults }: { ignoreDefaults: boolean }) [orderedProjects], ); const stagedDocuments = stagedFiles.filter((file) => file.kind === "document"); - const stagedAttachments = stagedFiles.filter((file) => file.kind === "attachment"); + const isStagedImage = (item: StagedIssueFile) => isImageAttachment({ name: item.file.name, url: "", contentType: item.file.type }); + const stagedAttachments = stagedFiles.filter((file) => file.kind === "attachment" && !isStagedImage(file)); const handleProjectChange = useCallback( (nextProjectId: string) => { @@ -1459,6 +1454,7 @@ function AccountNewIssueDialog({ ignoreDefaults }: { ignoreDefaults: boolean }) onDragLeave={handleFileDragLeave} onDrop={handleFileDrop} > + {filesStorageError ?

{filesStorageError}

: null} { - if (!effectiveCompanyId) throw new Error("No organization selected"); - const asset = await assetsApi.uploadImage(effectiveCompanyId, file, "issues/drafts"); - return asset.contentPath; - }} companyId={effectiveCompanyId} enableReassign reassignOptions={assigneeOptions} @@ -1512,6 +1503,8 @@ function AccountNewIssueDialog({ ignoreDefaults }: { ignoreDefaults: boolean }) submitLabel: isSubIssueMode ? "Create sub-task" : "Create task", canSubmitWithoutBody: Boolean(title.trim()), onSelectFiles: stageFiles, + images: stagedFiles.filter(isStagedImage).map(item => ({ id: item.id, name: item.file.name, file: item.file })), + onRemoveImage: removeStagedFile, runSettings: composerSettings, onRunSettingsChange: setComposerSettings, header: @@ -1591,7 +1584,7 @@ function AccountNewIssueDialog({ ignoreDefaults }: { ignoreDefaults: boolean }) : "The selected worktree is no longer available. Choose another worktree or start a new one."}

) : null} - {stagedFiles.length > 0 ? ( + {stagedDocuments.length > 0 || stagedAttachments.length > 0 ? (
{stagedDocuments.length > 0 ? (
@@ -1724,8 +1717,8 @@ function AccountNewIssueDialog({ ignoreDefaults }: { ignoreDefaults: boolean }) ) : null} ), - submitDisabled: worktreeSelectionIncomplete || parentPrivacyUnresolved || Boolean(durableCreate.pending), - readOnly: Boolean(durableCreate.pending), + submitDisabled: filesRestoring || worktreeSelectionIncomplete || parentPrivacyUnresolved || Boolean(durableCreate.pending), + readOnly: filesRestoring || Boolean(durableCreate.pending), contextBar: ( <> void; + onRetry?: (id: string) => void; +}) { + const [preview, setPreview] = useState(); + useEffect(() => { + if (!image.file || typeof URL.createObjectURL !== "function") return; + const url = URL.createObjectURL(image.file); + setPreview(url); + return () => URL.revokeObjectURL(url); + }, [image.file]); + const src = preview ?? image.contentPath; + + return ( +
+
+ {src ? ( + {image.name} + ) : ( +
+ +
+ )} + {image.status === "uploading" ? ( +
+ + Uploading {image.name}… +
+ ) : null} + + {image.status === "error" && onRetry ? ( + + ) : null} +
+ {image.status === "error" ? ( +

{image.error ?? "Upload failed"}

+ ) : null} +
+ ); +} + +export function ComposerImageAttachments({ images, disabled, onRemove, onRetry }: { + images: ComposerImageAttachment[]; + disabled?: boolean; + onRemove: (id: string) => void; + onRetry?: (id: string) => void; +}) { + if (!images.length) return null; + return ( +
+ {images.map(image => ( + + ))} +
+ ); +} diff --git a/ui/src/components/task-chat/TaskChatComposer.test.tsx b/ui/src/components/task-chat/TaskChatComposer.test.tsx index f124e7ef17a..a8dbf68b994 100644 --- a/ui/src/components/task-chat/TaskChatComposer.test.tsx +++ b/ui/src/components/task-chat/TaskChatComposer.test.tsx @@ -31,13 +31,6 @@ vi.mock("../../api/auth", async (importOriginal) => { return { ...actual, authApi: { ...actual.authApi, getSession: () => new Promise(() => {}) } }; }); -/** - * MDXEditor-in-jsdom weight (mirrors MarkdownEditor.test.tsx): the real editor - * is mocked with a contenteditable bridge — typing is simulated by setting - * textContent and dispatching an input event, imperative setMarkdown / - * insertMarkdown mutate the same content, and imagePlugin's config is captured - * so the inline upload handler can be exercised directly. - */ const mdxEditorMockState = vi.hoisted(() => ({ imagePluginOptions: null as { imageUploadHandler?: (file: File) => Promise; @@ -313,40 +306,33 @@ function renderComposer(props: ComponentProps) { } describe("TaskChatComposer", () => { - it("keeps creation images inline and prevents submission during their upload", async () => { - let resolveUpload!: (url: string) => void; - const onImageUpload = vi.fn().mockReturnValue(new Promise((resolve) => { resolveUpload = resolve; })); + it("stages creation images above the editor without changing the text", async () => { const creation = { value: "Before the screenshot", - onChange: vi.fn(), - onSubmit: vi.fn().mockResolvedValue(undefined), - submitLabel: "Create task", - onSelectFiles: vi.fn(), - runSettings: null, - onRunSettingsChange: vi.fn(), + onChange: vi.fn(), onSubmit: vi.fn().mockResolvedValue(undefined), + submitLabel: "Create task", onSelectFiles: vi.fn(), onRemoveImage: vi.fn(), + runSettings: null, onRunSettingsChange: vi.fn(), }; - render(); + render(); const image = new File(["png"], "screen.png", { type: "image/png" }); - expect(pasteFiles([image]).defaultPrevented).toBe(false); - expect(creation.onSelectFiles).not.toHaveBeenCalled(); - const handler = mdxEditorMockState.imagePluginOptions!.imageUploadHandler!; - const upload = handler(image); - await flushAsync(); - expect(sendButton().disabled).toBe(true); - pressKey("Enter", { metaKey: true }); - expect(creation.onSubmit).not.toHaveBeenCalled(); - resolveUpload("/api/assets/screen/content"); - const url = await upload; - const value = `Before the screenshot\n\n![screen](${url})\n\nAfter the screenshot`; - render(); + expect(pasteFiles([image]).defaultPrevented).toBe(true); + expect(creation.onSelectFiles).toHaveBeenCalledWith([image]); + render(); await flushAsync(); + const row = container.querySelector('[data-testid="task-chat-composer-images"]')!; + const input = container.querySelector('[data-testid="task-chat-composer-input"]')!; + expect(row.compareDocumentPosition(input) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(row.querySelector('img')?.alt).toBe("screen.png"); + expect(editable().textContent).toBe(creation.value); + expect(creation.onChange).not.toHaveBeenCalled(); + flushSync(() => container.querySelector('button[aria-label="Remove screen.png"]')!.click()); + expect(creation.onRemoveImage).toHaveBeenCalledWith("screen"); flushSync(() => sendButton().click()); await flushAsync(); - expect(creation.onSubmit).toHaveBeenCalledWith(value, "standard", null); - expect(onImageUpload).toHaveBeenCalledWith(image); + expect(creation.onSubmit).toHaveBeenCalledWith(creation.value, "standard", null); }); - it("stages non-image creation paste files while images reach the editor", () => { + it("stages images and documents together when pasted into creation", () => { const creation = { value: "Inspect these files", onChange: vi.fn(), onSubmit: vi.fn().mockResolvedValue(undefined), @@ -356,8 +342,8 @@ describe("TaskChatComposer", () => { render(); const image = new File(["png"], "screen.png", { type: "image/png" }); const document = new File(["notes"], "notes.txt", { type: "text/plain" }); - expect(pasteFiles([image, document]).defaultPrevented).toBe(false); - expect(creation.onSelectFiles).toHaveBeenCalledWith([document]); + expect(pasteFiles([image, document]).defaultPrevented).toBe(true); + expect(creation.onSelectFiles).toHaveBeenCalledWith([image, document]); expect(pasteFiles([document]).defaultPrevented).toBe(true); }); @@ -842,7 +828,7 @@ describe("TaskChatComposer", () => { expect(sendButton().disabled).toBe(false); }); - it("holds submission while an inline image upload is pending and binds its receipt", async () => { + it("holds submission while an image upload is pending and binds its receipt", async () => { const onAdd = vi.fn().mockResolvedValue(undefined); const id = "5e5f9946-c706-4785-8988-d4d6f0f499ab"; let resolveUpload!: (value: unknown) => void; @@ -857,10 +843,7 @@ describe("TaskChatComposer", () => { onAttachImage: onAttachImage, }); typeText("Inspect the picture."); - const handler = mdxEditorMockState.imagePluginOptions!.imageUploadHandler!; - const upload = handler( - new File(["png"], "image.png", { type: "image/png" }), - ); + pasteFiles([new File(["png"], "image.png", { type: "image/png" })]); await flushAsync(); expect(sendButton().disabled).toBe(true); pressKey("Enter", { metaKey: true }); @@ -870,9 +853,9 @@ describe("TaskChatComposer", () => { contentPath: `/api/attachments/${id}/content`, originalFilename: "image.png", }); - const url = await upload; - typeText(`Inspect the picture.\n\n![image](${url})`); await flushAsync(); + expect(editable().textContent).toBe("Inspect the picture."); + expect(container.querySelector('img[alt="image.png"]')).not.toBeNull(); flushSync(() => sendButton().click()); await flushAsync(); expect(onAdd.mock.calls[0]?.[3]).toEqual([id]); @@ -915,12 +898,8 @@ describe("TaskChatComposer", () => { new File(["two"], "two.txt", { type: "text/plain" }), ]); await flushAsync(); - const handler = mdxEditorMockState.imagePluginOptions!.imageUploadHandler!; - const retainedImage = await handler( - new File(["png"], "three.png", { type: "image/png" }), - ); - await handler(new File(["png"], "removed.png", { type: "image/png" })); - typeText(`Only this picture remains ![three](${retainedImage})`); + pasteFiles([new File(["png"], "three.png", { type: "image/png" }), new File(["png"], "removed.png", { type: "image/png" })]); + typeText("Only this picture remains"); await flushAsync(); flushSync(() => container @@ -929,6 +908,7 @@ describe("TaskChatComposer", () => { )! .click(), ); + flushSync(() => container.querySelector('button[aria-label="Remove removed.png"]')!.click()); flushSync(() => sendButton().click()); await flushAsync(); expect(onAdd.mock.calls[0]?.[3]).toEqual([receipts[0], receipts[2]]); @@ -936,7 +916,7 @@ describe("TaskChatComposer", () => { expect(onAdd.mock.calls[0]?.[0]).not.toContain("removed.png"); }); - it("restores file and inline image receipts after a fresh composer mount", async () => { + it("restores file and image previews after a fresh composer mount", async () => { const onAdd = vi.fn().mockResolvedValue(undefined); const ids = [ "9af8228f-0be7-45ae-a104-6fbe0af6f1d3", @@ -959,17 +939,15 @@ describe("TaskChatComposer", () => { render(); pasteFiles([new File(["text"], "file.txt", { type: "text/plain" })]); await flushAsync(); - const url = await mdxEditorMockState.imagePluginOptions! - .imageUploadHandler!( - new File(["png"], "image.png", { type: "image/png" }), - ); - typeText(`Inspect both files ![image](${url})`); + pasteFiles([new File(["png"], "clipboard-image", { type: "image/png" })]); + typeText("Inspect both files"); await flushAsync(); render(
); render(); await flushAsync(); expect(container.textContent).toContain("file.txt"); - expect(editable().textContent).toContain(url); + expect(editable().textContent).toBe("Inspect both files"); + expect(container.querySelector('img[alt="clipboard-image"]')?.getAttribute("src")).toBe(`/api/attachments/${ids[1]}/content`); flushSync(() => sendButton().click()); await flushAsync(); expect(onAdd.mock.calls[0]?.[3]).toEqual(ids); @@ -977,7 +955,7 @@ describe("TaskChatComposer", () => { expect(localStorage.getItem("receipt-reload:attachments:v1")).toBeNull(); }); - it("removes a pending inline image without a late insertion or restored receipt", async () => { + it("removes a pending image without a late insertion or restored receipt", async () => { const onAdd = vi.fn().mockResolvedValue(undefined); let resolveUpload!: (value: unknown) => void; const onAttachImage = vi.fn().mockReturnValue( @@ -992,10 +970,7 @@ describe("TaskChatComposer", () => { draftKey: "removed-pending", }); typeText("Keep this text"); - const upload = mdxEditorMockState.imagePluginOptions!.imageUploadHandler!( - new File(["png"], "pending.png", { type: "image/png" }), - ); - const rejected = expect(upload).rejects.toThrow("Attachment was removed"); + pasteFiles([new File(["png"], "pending.png", { type: "image/png" })]); await flushAsync(); expect(container.textContent).toContain("pending.png"); expect(sendButton().disabled).toBe(true); @@ -1011,7 +986,6 @@ describe("TaskChatComposer", () => { contentPath: "/api/attachments/9af8228f-0be7-45ae-a104-6fbe0af6f1d3/content", }); - await rejected; await flushAsync(); expect(editable().textContent).toBe("Keep this text"); expect(localStorage.getItem("removed-pending:attachments:v1")).toBeNull(); @@ -1339,32 +1313,62 @@ describe("TaskChatComposer", () => { expect(openPicker).toHaveBeenCalledOnce(); }); - it("wires the editor's inline image upload to onAttachImage and returns the attachment URL", async () => { - const onAttachImage = vi.fn().mockResolvedValue({ - contentPath: "/attachments/shot.png", - originalFilename: "shot.png", - }); - renderComposer({ - onAdd: vi.fn(), - workMode: "standard", - onAttachImage: onAttachImage, - }); - - const handler = mdxEditorMockState.imagePluginOptions?.imageUploadHandler; - expect(handler).toBeTypeOf("function"); - + it.each(["picker", "drop"])("uploads images from %s into a preview row above the text", async (source) => { + const onAdd = vi.fn(); + const onAttachImage = vi.fn().mockResolvedValue({ contentPath: "/attachments/shot.png" }); + renderComposer({ onAdd, workMode: "standard", onAttachImage }); + typeText("Review this image"); const file = new File(["png-bytes"], "shot.png", { type: "image/png" }); - await expect(handler!(file)).resolves.toBe("/attachments/shot.png"); - expect(onAttachImage).toHaveBeenCalledWith(file); - // Inline images do not go through the attachment chip row. - expect( - container.querySelector('[data-testid="task-chat-composer-attachments"]'), - ).toBeNull(); + if (source === "picker") { + const input = container.querySelector('input[type="file"]')!; + Object.defineProperty(input, "files", { value: [file] }); + flushSync(() => input.dispatchEvent(new Event("change", { bubbles: true }))); + } else { + const event = new Event("drop", { bubbles: true, cancelable: true }); + Object.defineProperty(event, "dataTransfer", { value: { files: [file] } }); + flushSync(() => editable().dispatchEvent(event)); + expect(event.defaultPrevented).toBe(true); + } + await flushAsync(); + const row = container.querySelector('[data-testid="task-chat-composer-images"]')!; + expect(row.querySelector('img')?.getAttribute("src")).toMatch(/^blob:/); + expect(row.compareDocumentPosition(editable()) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(editable().textContent).toBe("Review this image"); + expect(mdxEditorMockState.imagePluginOptions?.imageUploadHandler).toBeUndefined(); + flushSync(() => sendButton().click()); + await flushAsync(); + expect(onAdd.mock.calls[0]?.[0]).toBe("Review this image\n\n![shot.png](/attachments/shot.png)"); }); - it("does not register the image plugin without an upload handler", () => { - render(); - expect(mdxEditorMockState.imagePluginOptions).toBeNull(); + it("keeps failed image uploads in the preview row for retry without changing the text", async () => { + const onAdd = vi.fn(); + const onAttachImage = vi.fn().mockRejectedValueOnce(new Error("Too large")) + .mockResolvedValueOnce({ contentPath: "/attachments/retried.png" }); + renderComposer({ onAdd, workMode: "standard", onAttachImage }); + typeText("Keep the caption"); + const file = new File(["png"], "retry.png", { type: "image/png" }); + pasteFiles([file]); + await flushAsync(); + expect(sendButton().disabled).toBe(true); + const retry = container.querySelector('button[aria-label="Retry retry.png"]')!; + expect(retry).not.toBeNull(); + flushSync(() => retry.click()); + await flushAsync(); + expect(onAttachImage).toHaveBeenCalledTimes(2); + expect(onAttachImage.mock.calls[1]?.[0]).toBe(file); + expect(editable().textContent).toBe("Keep the caption"); + expect(sendButton().disabled).toBe(false); + flushSync(() => sendButton().click()); + await flushAsync(); + expect(onAdd.mock.calls[0]?.[0]).toBe("Keep the caption\n\n![retry.png](/attachments/retried.png)"); + }); + + it("keeps existing image Markdown editable without an inline upload handler", () => { + localStorage.setItem("image-draft", "Caption\n\n![old image](/api/attachments/old/content)"); + render(); + expect(editable().textContent).toContain("![old image](/api/attachments/old/content)"); + expect(mdxEditorMockState.imagePluginOptions).not.toBeNull(); + expect(mdxEditorMockState.imagePluginOptions?.imageUploadHandler).toBeUndefined(); }); it("attaches pasted non-image files to the chip row and posts a link reference", async () => { @@ -1555,7 +1559,7 @@ describe("TaskChatComposer", () => { expect(chips?.textContent).toContain("Too large"); }); - it("leaves pasted images to the editor's image plugin (no chip, paste not swallowed)", async () => { + it("handles mixed image and document pastes without inserting images into the editor", async () => { const onAttachImage = vi.fn().mockResolvedValue({ contentPath: "/attachments/notes.txt", originalFilename: "notes.txt", @@ -1571,15 +1575,17 @@ describe("TaskChatComposer", () => { const paste = pasteFiles([image, text]); await flushAsync(); - // Mixed paste: the non-image is chipped, the image flows through untouched. - expect(paste.defaultPrevented).toBe(false); + expect(paste.defaultPrevented).toBe(true); const chips = container.querySelector( '[data-testid="task-chat-composer-attachments"]', )!; expect(chips.textContent).toContain("notes.txt"); expect(chips.textContent).not.toContain("shot.png"); - expect(onAttachImage).toHaveBeenCalledTimes(1); + expect(container.querySelector('img[alt="shot.png"]')).not.toBeNull(); + expect(editable().textContent).toBe(""); + expect(onAttachImage).toHaveBeenCalledTimes(2); expect(onAttachImage).toHaveBeenCalledWith(text); + expect(onAttachImage).toHaveBeenCalledWith(image); }); it("inserts an @-mention from the autocomplete menu and posts it", async () => { diff --git a/ui/src/components/task-chat/TaskChatComposer.tsx b/ui/src/components/task-chat/TaskChatComposer.tsx index 9968403ebaa..f7a2f411a26 100644 --- a/ui/src/components/task-chat/TaskChatComposer.tsx +++ b/ui/src/components/task-chat/TaskChatComposer.tsx @@ -54,7 +54,7 @@ import { AttachmentMedia, AttachmentTitle, } from "@/components/ui/attachment"; -import { fileKindForName, formatFileSize } from "./task-chat-attachments"; +import { fileKindForName, formatFileSize, isImageAttachment } from "./task-chat-attachments"; import { MarkdownEditor, type MarkdownEditorRef, @@ -73,6 +73,7 @@ import type { ActionCommandOption } from "@/context/EditorAutocompleteContext"; import { TaskChatComposerTakeoverActionsContext } from "./TaskChatComposerTakeoverContext"; import { TaskChatPausedTakeover, type TaskComposerPause } from "./TaskChatPausedTakeover"; +import { ComposerImageAttachments, type ComposerImageAttachment } from "./ComposerImageAttachments"; import { ComposerRunSettingsPicker } from "./ComposerRunSettingsPicker"; import { TaskChatComposerBar } from "./TaskChatComposerBar"; import { ComposerAddMenu, ComposerModeChip, ComposerPrivacyChip } from "./ComposerAddMenu"; @@ -126,6 +127,8 @@ interface TaskChatComposerProps { readOnly?: boolean; privacy?: { private: boolean; inherited?: string; onChange: (value: boolean) => void }; onSelectFiles: (files: File[]) => void; + images?: ComposerImageAttachment[]; + onRemoveImage?: (id: string) => void; runSettings: ComposerRunSettings | null; onRunSettingsChange: (settings: ComposerRunSettings | null) => void; }; @@ -140,7 +143,6 @@ interface TaskChatComposerProps { placeholder?: string; /** Preferred upload path: attaches the file to the task (mirrors legacy). */ onAttachImage?: (file: File) => Promise; - /** Fallback upload path: returns a URL for inline image markdown. */ onImageUpload?: (file: File) => Promise; /** Mentionable entities for the editor's @-autocomplete. */ mentions?: MentionOption[]; @@ -332,6 +334,7 @@ type ComposerAttachment = { id: string; attachmentId?: string; inline?: boolean; + contentType?: string; name: string; size?: number; status: "uploading" | "attached" | "error"; @@ -381,18 +384,6 @@ function escapeMarkdownLabel(name: string): string { return name.replace(/[[\]]/g, "\\$&"); } -/** - * Composer for the redesigned thread (v7 spec): the shared MarkdownEditor - * (rich lists, @-mentions, /-commands, inline pasted images) over a 32px - * comp-bar of [add] [optional mode chip] … [assignee] [send]. The add menu - * offers files, supported goals, Plan, and Ask. Cmd/Ctrl+. and Shift+Tab cycle modes (captured before - * Lexical); Cmd/Ctrl+Enter posts via the editor's native onSubmit; plain Enter - * stays a newline / next list item. Pasted or dropped images upload through - * `onAttachImage` (or the `onImageUpload` fallback) and land inline at the - * caret via the editor's image plugin; non-image files render as shadcn - * base/attachment chips (kind icon · name · size, remove ×, uploading/error - * states) between the editor and the comp-bar. - */ export function TaskChatComposer({ onAdd, creation, @@ -622,6 +613,7 @@ export function TaskChatComposer({ name: item.name, size: item.size, inline: item.inline === true, + contentType: item.contentType, contentPath: item.contentPath, })), ); @@ -708,8 +700,7 @@ export function TaskChatComposer({ if (creation) void onWorkModeChange?.(mode); } - /** Upload an image and return its URL for inline `![](src)` markdown. */ - async function uploadInlineImage(file: File): Promise { + async function uploadImage(file: File): Promise { const id = crypto.randomUUID(); setAttachments((prev) => [ ...prev, @@ -717,7 +708,8 @@ export function TaskChatComposer({ id, name: file.name, size: file.size, - inline: true, + contentType: file.type, + file, status: "uploading", }, ]); @@ -764,7 +756,7 @@ export function TaskChatComposer({ const id = `${file.name}:${file.size}:${file.lastModified}:${Math.random().toString(36).slice(2)}`; setAttachments((prev) => [ ...prev, - { id, name: file.name, size: file.size, status: "uploading" }, + { id, name: file.name, size: file.size, contentType: file.type, status: "uploading" }, ]); try { if (!onAttachImage) { @@ -822,20 +814,12 @@ export function TaskChatComposer({ void attachPickedFile(file); } - /** Images picked from the + button go inline at the caret, like paste/drop. */ async function attachPickedFile(file: File) { if (!file.type.startsWith("image/")) { await attachNonImageFile(file); return; } - try { - const url = await uploadInlineImage(file); - editorRef.current?.insertMarkdown( - `![${escapeMarkdownLabel(file.name)}](${url})`, - ); - } catch { - // uploadInlineImage retains the failed receipt in the removable chip row. - } + await uploadImage(file).catch(() => undefined); } function handleFileInputChange(evt: ChangeEvent) { @@ -860,46 +844,29 @@ export function TaskChatComposer({ requestAnimationFrame(() => editorRef.current?.focus()); } - /** - * Pasted image files fall through to the editor's image plugin (inline at - * the caret); non-image files are attached to the task here. Only swallow - * the paste when it carries no images the plugin should handle. - */ function handlePasteCapture(evt: ReactClipboardEvent) { - if (pause || !canAcceptFiles) return; + if (!canAcceptFiles || disabled || creation?.readOnly) return; const files = Array.from(evt.clipboardData?.files ?? []); if (files.length === 0) return; + evt.preventDefault(); + evt.stopPropagation(); if (creation) { - if (onImageUpload) { - const nonImages = files.filter((file) => !file.type.startsWith("image/")); - if (nonImages.length === 0) return; - creation.onSelectFiles(nonImages); - if (nonImages.length !== files.length) return; - } else { - creation.onSelectFiles(files); - } - evt.preventDefault(); - evt.stopPropagation(); + creation.onSelectFiles(files); return; } - const nonImages = files.filter((file) => !file.type.startsWith("image/")); - if (nonImages.length === 0) return; - if (nonImages.length === files.length) { - evt.preventDefault(); - evt.stopPropagation(); - } - void (async () => { - for (const file of nonImages) await attachNonImageFile(file); - })(); + for (const file of files) void attachPickedFile(file); + } + + function isImage(item: ComposerAttachment) { + return isImageAttachment({ name: item.name, url: item.contentPath ?? "", contentType: item.contentType }); } - // Uploaded file references ride along as trailing `[name](contentPath)` - // lines — the bubble renderer folds those link-only lines back into chips. const attachedRefs = attachments.filter( (item) => item.status === "attached" && item.contentPath && !item.inline, ); + const imageAttachments = attachments.filter(item => isImage(item) && !item.inline); const visibleAttachments = attachments.filter( - (item) => !item.inline || item.status !== "attached", + (item) => (!item.inline || item.status !== "attached") && !isImage(item), ); // Sending mid-upload would silently drop the pending file from the comment; // sending past a failed chip would discard the file the user selected and @@ -1024,7 +991,7 @@ export function TaskChatComposer({ return; const refLines = attachedRefs - .map((item) => `[${escapeMarkdownLabel(item.name)}](${item.contentPath})`) + .map((item) => `${isImage(item) ? "!" : ""}[${escapeMarkdownLabel(item.name)}](${item.contentPath})`) .join("\n"); // A queued edit must preserve the complete Markdown source. Normal sends // keep the longstanding trimmed-body behavior. @@ -1366,6 +1333,17 @@ export function TaskChatComposer({ } }} onPasteCapture={handlePasteCapture} + onDragOver={(event) => { + if (canAcceptFiles && !disabled && !creation && event.dataTransfer.types.includes("Files")) event.preventDefault(); + }} + onDropCapture={(event) => { + if (!canAcceptFiles || disabled || creation) return; + const files = Array.from(event.dataTransfer.files); + if (!files.length) return; + event.preventDefault(); + event.stopPropagation(); + for (const file of files) void attachPickedFile(file); + }} > {creation?.header} {uncertainSubmission ? ( @@ -1401,6 +1379,18 @@ export function TaskChatComposer({

Send /new to start a fresh session and resume this conversation.

) : null} + { + if (creation) creation.onRemoveImage?.(id); + else setAttachments(current => current.filter(item => item.id !== id)); + }} + onRetry={creation ? undefined : (id) => { + const image = attachments.find(item => item.id === id); + if (image) retryUpload(image); + }} + />
void submit()} - imageUploadHandler={ - canAcceptFiles && (!creation || onImageUpload) ? uploadInlineImage : undefined - } - onDropFile={canAcceptFiles && !creation ? attachNonImageFile : undefined} + fileDropTarget="parent" bordered={false} className={cn(disabled && "opacity-60")} contentClassName={ @@ -1521,7 +1508,7 @@ export function TaskChatComposer({ >
{canAcceptFiles ? ( - + ) : null} ({ + readStagedIssueFiles: vi.fn(), + writeStagedIssueFiles: vi.fn(), +})); + +const image: StagedIssueFile = { id: "image", kind: "attachment", file: new File(["bytes"], "reference.png", { type: "image/png" }) }; +let current: ReturnType; +function Harness({ owner = "user-a", company = "company-a", pending = false }: { owner?: string; company?: string; pending?: boolean }) { + current = useStagedIssueFiles(pending ? undefined : owner, company); + return
{current.files.map(item => item.file.name).join(",")}{current.error}
; +} + +describe("staged task file recovery", () => { + let root: Root; + let container: HTMLDivElement; + beforeEach(() => { + vi.mocked(readStagedIssueFiles).mockReset().mockResolvedValue([]); + vi.mocked(writeStagedIssueFiles).mockReset().mockResolvedValue(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + afterEach(async () => { await act(async () => root.unmount()); container.remove(); }); + + it.each(["owner", "company"] as const)("ignores an old restore after the %s changes", async (field) => { + let release!: (files: StagedIssueFile[]) => void; + vi.mocked(readStagedIssueFiles).mockReturnValueOnce(new Promise(resolve => { release = resolve; })); + await act(async () => root.render()); + await act(async () => root.render()); + await act(async () => release([image])); + expect(container.textContent).not.toContain("reference.png"); + expect(current.restoring).toBe(false); + expect(writeStagedIssueFiles).not.toHaveBeenCalled(); + }); + + it("retains selected files and reports unavailable browser storage", async () => { + vi.mocked(writeStagedIssueFiles).mockRejectedValue(new Error("quota exceeded")); + await act(async () => root.render()); + await act(async () => current.setFiles([image])); + expect(container.textContent).toContain("reference.png"); + expect(container.textContent).toContain("Keep this page open"); + vi.mocked(writeStagedIssueFiles).mockResolvedValue(); + await act(async () => current.setFiles([])); + expect(container.textContent).toBe(""); + }); + + it("allows file selection after a failed restore and clears the error after saving", async () => { + vi.mocked(readStagedIssueFiles).mockRejectedValue(new Error("storage unavailable")); + await act(async () => root.render()); + expect(container.textContent).toContain("Saved files could not be restored"); + expect(current.restoring).toBe(false); + await act(async () => current.setFiles([image])); + expect(container.textContent).toBe("reference.png"); + }); + + it("keeps files selected before the account is known", async () => { + await act(async () => root.render()); + await act(async () => current.setFiles([image])); + await act(async () => root.render()); + expect(container.textContent).toBe("reference.png"); + expect(writeStagedIssueFiles).toHaveBeenCalledWith(JSON.stringify(["user-a", "company-a"]), [image], []); + }); + + it("deletes only files removed from this mounted draft", async () => { + await act(async () => root.render()); + await act(async () => current.setFiles([image])); + await act(async () => current.setFiles([])); + expect(writeStagedIssueFiles).toHaveBeenLastCalledWith(JSON.stringify(["user-a", "company-a"]), [], [image.id]); + await act(async () => current.setFiles([image])); + expect(writeStagedIssueFiles).toHaveBeenLastCalledWith(JSON.stringify(["user-a", "company-a"]), [image], []); + }); +}); diff --git a/ui/src/hooks/useStagedIssueFiles.ts b/ui/src/hooks/useStagedIssueFiles.ts new file mode 100644 index 00000000000..242d7d2a6bd --- /dev/null +++ b/ui/src/hooks/useStagedIssueFiles.ts @@ -0,0 +1,60 @@ +import { useCallback, useEffect, useState, type SetStateAction } from "react"; +import { readStagedIssueFiles, writeStagedIssueFiles, type StagedIssueFile } from "../lib/staged-issue-files"; + +export function useStagedIssueFiles(owner: string | undefined, companyId: string | null | undefined) { + const key = owner !== undefined && companyId ? JSON.stringify([owner, companyId]) : null; + const [state, setState] = useState<{ + key: string | null; + files: StagedIssueFile[]; + removedIds: string[]; + restoring: boolean; + changed: boolean; + }>({ key, files: [], removedIds: [], restoring: Boolean(key), changed: false }); + const [error, setError] = useState(null); + + useEffect(() => { + let active = true; + const unboundFiles = state.key === null ? state.files : []; + setError(null); + setState({ key, files: unboundFiles, removedIds: [], restoring: Boolean(key), changed: false }); + if (key) { + void readStagedIssueFiles(key).then((files) => { + if (active) setState({ key, files: [...files, ...unboundFiles], removedIds: [], restoring: false, changed: unboundFiles.length > 0 }); + }).catch(() => { + if (!active) return; + setState({ key, files: unboundFiles, removedIds: [], restoring: false, changed: unboundFiles.length > 0 }); + setError("Saved files could not be restored. Attach them again before you create the task."); + }); + } + return () => { active = false; }; + }, [key]); + + useEffect(() => { + if (!key || state.key !== key || state.restoring || !state.changed) return; + let active = true; + void writeStagedIssueFiles(key, state.files, state.removedIds).then(() => { + if (active) setError(null); + }).catch(() => { + if (active) setError("Files could not be saved in this browser. Keep this page open until you create the task."); + }); + return () => { active = false; }; + }, [key, state]); + + const setFiles = useCallback((update: SetStateAction) => { + setState((current) => { + if (current.key !== key || current.restoring) return current; + const files = typeof update === "function" ? update(current.files) : update; + const retainedIds = new Set(files.map(file => file.id)); + const removedIds = [...new Set([...current.removedIds, ...current.files.map(file => file.id)])] + .filter(id => !retainedIds.has(id)); + return { ...current, files, removedIds, changed: true }; + }); + }, [key]); + + return { + files: state.key === key ? state.files : [], + setFiles, + restoring: state.key !== key || state.restoring, + error, + }; +} diff --git a/ui/src/lib/composer-draft.ts b/ui/src/lib/composer-draft.ts index 464867728cd..4003c92a289 100644 --- a/ui/src/lib/composer-draft.ts +++ b/ui/src/lib/composer-draft.ts @@ -239,6 +239,7 @@ export interface ComposerDraftAttachment { name: string; size?: number; inline: boolean; + contentType?: string; contentPath: string; } @@ -280,6 +281,7 @@ function draftAttachments(value: unknown): ComposerDraftAttachment[] { name: row.name, inline: row.inline, contentPath: row.contentPath, + ...(typeof row.contentType === "string" ? { contentType: row.contentType } : {}), ...(row.size === undefined ? {} : { size: row.size as number }), }); } diff --git a/ui/src/lib/staged-issue-files.ts b/ui/src/lib/staged-issue-files.ts new file mode 100644 index 00000000000..4367ce76ecc --- /dev/null +++ b/ui/src/lib/staged-issue-files.ts @@ -0,0 +1,55 @@ +export interface StagedIssueFile { + id: string; + file: File; + kind: "document" | "attachment"; + documentKey?: string; + title?: string | null; +} + +let database: Promise | undefined; + +function openDatabase(): Promise { + database ??= new Promise((resolve, reject) => { + const request = indexedDB.open("paperclip:issue-draft-files", 1); + request.onupgradeneeded = () => request.result.createObjectStore("files"); + request.onerror = () => reject(request.error); + request.onblocked = () => reject(new Error("Draft file storage is blocked")); + request.onsuccess = () => { + const db = request.result; + db.onversionchange = () => { db.close(); database = undefined; }; + resolve(db); + }; + }).catch((error) => { database = undefined; throw error; }); + return database; +} + +export async function readStagedIssueFiles(key: string): Promise { + const db = await openDatabase(); + return new Promise((resolve, reject) => { + const transaction = db.transaction("files", "readonly"); + const request = transaction.objectStore("files").get(key); + transaction.oncomplete = () => resolve(request.result ?? []); + transaction.onabort = () => reject(transaction.error); + transaction.onerror = () => reject(transaction.error); + }); +} + +export async function writeStagedIssueFiles(key: string, files: StagedIssueFile[], removedIds: string[]): Promise { + const db = await openDatabase(); + return new Promise((resolve, reject) => { + const transaction = db.transaction("files", "readwrite"); + const store = transaction.objectStore("files"); + const request = store.get(key); + request.onsuccess = () => { + const removed = new Set(removedIds); + const saved = (request.result ?? []) as StagedIssueFile[]; + const merged = new Map(saved.filter(file => !removed.has(file.id)).map(file => [file.id, file])); + for (const file of files) merged.set(file.id, file); + if (merged.size) store.put([...merged.values()], key); + else store.delete(key); + }; + transaction.oncomplete = () => resolve(); + transaction.onabort = () => reject(transaction.error); + transaction.onerror = () => reject(transaction.error); + }); +} diff --git a/ui/storybook/stories/tasks/composer-add-menu.stories.tsx b/ui/storybook/stories/tasks/composer-add-menu.stories.tsx index e3e58b6b877..0b26f08d93f 100644 --- a/ui/storybook/stories/tasks/composer-add-menu.stories.tsx +++ b/ui/storybook/stories/tasks/composer-add-menu.stories.tsx @@ -1,8 +1,9 @@ -import { useState, type CSSProperties } from "react"; +import { useEffect, useRef, useState, type CSSProperties } from "react"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, waitFor, within } from "storybook/test"; import { ChevronLeft, Ellipsis } from "lucide-react"; import type { IssueAttachment, IssueWorkMode, RunnerGoalCapability } from "@paperclipai/shared"; +import { composerImageFiles } from "../../support/composerImageFiles"; import { MobileBottomNav } from "@/components/MobileBottomNav"; import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer"; import { TaskChatComposerDock } from "@/components/task-chat/TaskChatComposerDock"; @@ -39,17 +40,23 @@ interface ComposerAddStoryProps { } function ComposerAddStory({ initialMode, goalAvailable, mobile, mobileContext, fullBleedMobileContext }: ComposerAddStoryProps) { + const imageUrls = useRef([]); + useEffect(() => () => { + imageUrls.current.forEach(url => URL.revokeObjectURL(url)); + }, []); const [workMode, setWorkMode] = useState(initialMode); const [sent, setSent] = useState([]); const [goal, setGoal] = useState(null); async function attachFile(file: File): Promise { + const contentPath = URL.createObjectURL(file); + imageUrls.current.push(contentPath); return { id: crypto.randomUUID(), companyId: "storybook", issueId: "composer-story", issueCommentId: null, assetId: crypto.randomUUID(), provider: "storybook", objectKey: file.name, contentType: file.type, byteSize: file.size, sha256: "storybook", originalFilename: file.name, createdByAgentId: null, createdByUserId: "storybook", - createdAt: new Date(), updatedAt: new Date(), contentPath: URL.createObjectURL(file), + createdAt: new Date(), updatedAt: new Date(), contentPath, }; } @@ -117,11 +124,12 @@ const meta = { docs: { description: { component: "The production task composer. The Add menu opens upward on desktop and as a dialog on mobile for files, supported goals, Plan mode, and Ask mode. Plan and Ask are exclusive; selecting a mode shows a removable chip. Cmd+. cycles standard, Plan, and Ask. Mobile stories include capsule agent avatars and the actual bottom navigation." } }, paperclip: { "capabilities": [ - "tasks/create" + "tasks/create", + "tasks/discussion" ], "implementation": "production", "boundary": "fixtures", - "notes": "The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work." + "notes": "Images and MobileImages show the shared composer thumbnail row with local image uploads; no backend persistence is exercised. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work." }, }, args: { initialMode: "standard", goalAvailable: true, mobile: false, mobileContext: false, fullBleedMobileContext: false }, @@ -311,3 +319,22 @@ export const MobileGoalFromAddDialog: Story = { await waitFor(() => expect(editor).toHaveFocus()); }, }; + +export const Images: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const editor = await canvas.findByRole("textbox", { name: "editable markdown" }); + await userEvent.click(editor); + await userEvent.paste("Use these references to update the layout."); + const input = canvasElement.querySelector('input[type="file"]')!; + await userEvent.upload(input, await composerImageFiles()); + await expect(canvas.getByAltText("desktop-reference.jpg")).toBeVisible(); + await expect(canvas.getByAltText("mobile-reference.jpg")).toBeVisible(); + await expect(editor).toHaveTextContent("Use these references to update the layout."); + }, +}; +export const MobileImages: Story = { + ...Images, + args: { mobile: true, mobileContext: true }, + globals: { viewport: { value: "mobile", isRotated: false } }, +}; diff --git a/ui/storybook/stories/tasks/new-task-composer.stories.tsx b/ui/storybook/stories/tasks/new-task-composer.stories.tsx index be031e82379..be391c99d7b 100644 --- a/ui/storybook/stories/tasks/new-task-composer.stories.tsx +++ b/ui/storybook/stories/tasks/new-task-composer.stories.tsx @@ -12,6 +12,7 @@ import { buildUserMentionHref, } from "@paperclipai/shared"; import { MarkdownBody } from "@/components/MarkdownBody"; +import { composerImageFiles } from "../../support/composerImageFiles"; import { NewIssueDialog } from "@/components/NewIssueDialog"; import { useCompany } from "@/context/CompanyContext"; import { useDialog } from "@/context/DialogContext"; @@ -69,7 +70,6 @@ function NewTaskStory({ useLayoutEffect(() => { const originalFetch = window.fetch; - const imageUrls: string[] = []; window.fetch = async (input, init) => { const url = new URL( typeof input === "string" ? input : input instanceof URL ? input.href : input.url, @@ -83,13 +83,6 @@ function NewTaskStory({ if (worktrees === "error") return Response.json({ error: "Worktrees unavailable" }, { status: 503 }); return Response.json(worktrees === "empty" || url.searchParams.get("projectId") !== "project-board-ui" ? [] : WORKTREES); } - if (url.pathname === `/api/companies/${COMPANY_ID}/assets/images` && init?.method === "POST") { - const file = init.body instanceof FormData ? init.body.get("file") : null; - if (!(file instanceof File)) return Response.json({ error: "Select an image" }, { status: 400 }); - const contentPath = URL.createObjectURL(file); - imageUrls.push(contentPath); - return Response.json({ contentPath }); - } if (url.pathname === `/api/companies/${COMPANY_ID}/issues` && init?.method === "POST") { if (scenario === "saving") return new Promise(() => {}); if (scenario === "error") @@ -112,7 +105,6 @@ function NewTaskStory({ }; return () => { window.fetch = originalFetch; - imageUrls.forEach((url) => URL.revokeObjectURL(url)); }; }, [scenario, worktrees]); @@ -225,7 +217,7 @@ const meta = { "status": "current", "implementation": "production", "boundary": "fixtures", - "notes": "WorktreePicker, ReuseWorktree, CreateWithReusedWorktree and NewWorktreeAfterReuse exercise workspace selection and submitted fixture request values. Actual admission/binding and execution remain outside this story. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work." + "notes": "Images and MobileImages exercise staged image thumbnails above the editor; Files includes a document. These examples do not create a real task. WorktreePicker, ReuseWorktree, CreateWithReusedWorktree and NewWorktreeAfterReuse exercise workspace selection and submitted fixture request values. Actual admission/binding and execution remain outside this story. The filter uses one lowercase label without helper text. All chat-channel tasks count as bot-created; the focused visibility stories cover human-initiated channel work." }, }, args: { scenario: "empty" }, @@ -326,16 +318,33 @@ export const Files: Story = { const page = within(canvasElement.ownerDocument.body); await page.findByTestId("task-chat-composer-input"); const input = canvasElement.ownerDocument.querySelector('input[type="file"]')!; + const [image] = await composerImageFiles(); await userEvent.upload(input, [ new File(["# Sign-in plan\n\nReproduce the redirect."], "plan.md", { type: "text/markdown", }), - new File(["fixture"], "redirect.png", { type: "image/png" }), + image!, ]); await expect(page.getByText("plan.md")).toBeVisible(); - await expect(page.getByText("redirect.png")).toBeVisible(); + await expect(page.getByAltText("desktop-reference.jpg")).toBeVisible(); }, }; +export const Images: Story = { + args: { scenario: "prefilled" }, + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await page.findByTestId("task-chat-composer-input"); + const input = canvasElement.ownerDocument.querySelector('input[type="file"]')!; + await userEvent.upload(input, await composerImageFiles()); + await expect(page.getByAltText("desktop-reference.jpg")).toBeVisible(); + await expect(page.getByAltText("mobile-reference.jpg")).toBeVisible(); + await expect(page.getByRole("textbox", { name: "editable markdown" })).toHaveTextContent(REQUEST); + }, +}; +export const MobileImages: Story = { + ...Images, + globals: { viewport: { value: "mobile", isRotated: false } }, +}; export const Saving: Story = { args: { scenario: "saving" }, play: async ({ canvasElement }) => { diff --git a/ui/storybook/support/composerImageFiles.ts b/ui/storybook/support/composerImageFiles.ts new file mode 100644 index 00000000000..f79c4d8a71c --- /dev/null +++ b/ui/storybook/support/composerImageFiles.ts @@ -0,0 +1,11 @@ +export async function composerImageFiles() { + const sources = [ + ["/project-skills-evidence/sources-desktop.jpg", "desktop-reference.jpg"], + ["/project-skills-evidence/repair-conflict-mobile.jpg", "mobile-reference.jpg"], + ] as const; + return Promise.all(sources.map(async ([path, name]) => { + const response = await fetch(path); + if (!response.ok) throw new Error(`Image fixture unavailable: ${path}`); + return new File([await response.blob()], name, { type: "image/jpeg" }); + })); +}