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`);
+ }, { 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.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\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`);
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 `);
+ 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 `);
+ 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");
});
- 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");
+ });
+
+ it("keeps existing image Markdown editable without an inline upload handler", () => {
+ localStorage.setItem("image-draft", "Caption\n\n");
+ render();
+ expect(editable().textContent).toContain("");
+ 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 `` 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(
- ``,
- );
- } 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" });
+ }));
+}