Skip to content

Add composer drag-and-drop and image paste attachments#20

Open
SHAREN wants to merge 3 commits intofriuns2:mainfrom
SHAREN:codex/composer-drag-paste
Open

Add composer drag-and-drop and image paste attachments#20
SHAREN wants to merge 3 commits intofriuns2:mainfrom
SHAREN:codex/composer-drag-paste

Conversation

@SHAREN
Copy link
Copy Markdown
Contributor

@SHAREN SHAREN commented Mar 27, 2026

Summary

  • add composer drag-and-drop attachments for image and non-image files
  • support pasting clipboard images into the composer with Ctrl+V
  • bring over the dark-theme overlay follow-up so drag state stays readable in dark mode

Testing

  • npm run build
  • headless Playwright CLI on http://127.0.0.1:4173/#/
  • verified file drop creates a file chip
  • verified dragenter/dragover shows the composer overlay
  • verified image drop creates an image attachment
  • verified clipboard image paste creates an image attachment
  • verified plain-text paste is not prevented by the image handler

@qodo-code-review
Copy link
Copy Markdown

Review Summary by Qodo

Add composer drag-and-drop and image paste attachments

✨ Enhancement

Grey Divider

Walkthroughs

Description
• Add drag-and-drop file attachment support to composer
• Implement clipboard image paste with Ctrl+V functionality
• Block submit while attachments are processing with feedback
• Add dark theme styling for drag overlay and new states
Diagram
flowchart LR
  User["User Action"]
  DragDrop["Drag & Drop Files"]
  Paste["Paste Image Ctrl+V"]
  Picker["File Picker"]
  User --> DragDrop
  User --> Paste
  User --> Picker
  DragDrop --> Pipeline["Attachment Pipeline"]
  Paste --> Pipeline
  Picker --> Pipeline
  Pipeline --> ImageFile["Image File"]
  Pipeline --> OtherFile["Other File"]
  ImageFile --> DataUrl["Convert to Data URL"]
  OtherFile --> Upload["Upload to Server"]
  DataUrl --> Display["Display in Composer"]
  Upload --> Display
  Display --> Submit["Enable Submit Button"]
Loading

Grey Divider

File Changes

1. src/components/content/ThreadComposer.vue ✨ Enhancement +230/-23

Implement drag-drop, paste, and attachment pipeline

• Added drag-and-drop event handlers (onInputDragEnter, onInputDragOver, onInputDragLeave,
 onInputDrop) with drag depth tracking
• Implemented clipboard image paste handler (onInputPaste) that extracts image files and prevents
 default paste behavior
• Created unified attachment pipeline (attachIncomingFiles, attachImageFile,
 attachUploadedFile) with session token tracking for concurrent operations
• Added visual feedback with drag-active overlay showing "Drop images or files" message
• Blocked form submission while attachments are pending via pendingAttachmentCount check in
 canSubmit computed property
• Added attachment status feedback display below composer with dynamic messaging for pending
 attachments
• Refactored file handling with helper functions (normalizeSelectedFiles, ensureFileName,
 readFileAsDataUrl, hasFilePayload, createAttachmentId, createPastedImageName)
• Updated draft state replacement to reset attachment-related state variables

src/components/content/ThreadComposer.vue


2. src/style.css ✨ Enhancement +24/-0

Add dark theme styles for drag and attachment states

• Added dark theme overrides for drag-active shell state with adjusted border and shadow colors
• Added dark theme styling for drag-active input wrapper with semi-transparent background
• Added dark theme styling for drop overlay with inverted colors for readability
• Added dark theme styling for attachment feedback text
• Added dark theme placeholder text color for input field

src/style.css


Grey Divider

Qodo Logo

@qodo-code-review
Copy link
Copy Markdown

qodo-code-review bot commented Mar 27, 2026

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 📎 Requirement gaps (0) 📐 Spec deviations (0)

Grey Divider


Action required

1. Submit blocked by hung uploads🐞 Bug ⛯ Reliability
Description
canSubmit now returns false while pendingAttachmentCount > 0, but pendingAttachmentCount only
decrements when the underlying upload/read promise settles. Since uploadFile() uses fetch()
without any timeout/abort, a hung request can keep pendingAttachmentCount > 0 indefinitely and
permanently disable sending in the current thread.
Code

src/components/content/ThreadComposer.vue[R510-514]

if (props.disabled) return false
if (props.isUpdatingSpeedMode) return false
if (!props.activeThreadId) return false
+  if (pendingAttachmentCount.value > 0) return false
return draft.value.trim().length > 0 || selectedImages.value.length > 0 || fileAttachments.value.length > 0
Evidence
Submission is blocked whenever pendingAttachmentCount is non-zero, and the counter is only
decremented from finally blocks that run when async work completes. uploadFile() wraps a plain
fetch() with no timeout/abort, so if the request never resolves/rejects, finishAttachmentWork()
never runs and canSubmit stays false.

src/components/content/ThreadComposer.vue[509-515]
src/components/content/ThreadComposer.vue[836-845]
src/components/content/ThreadComposer.vue[916-933]
src/api/codexGateway.ts[883-893]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The composer disables submit while `pendingAttachmentCount > 0`, but an `uploadFile()` request can hang indefinitely because it uses `fetch()` without a timeout/abort. When that happens, `finishAttachmentWork()` never runs and the composer becomes permanently unsendable for that thread.
### Issue Context
`pendingAttachmentCount` is incremented in `beginAttachmentWork()` and decremented in `finishAttachmentWork()` called from `finally` blocks of `attachImageFile()`/`attachUploadedFile()`. If the awaited work never settles, the `finally` block never executes.
### Fix Focus Areas
- src/api/codexGateway.ts[883-893]
- src/components/content/ThreadComposer.vue[509-515]
- src/components/content/ThreadComposer.vue[836-845]
- src/components/content/ThreadComposer.vue[916-933]
### Suggested fix approach
- Add an `AbortController` + timeout (e.g., 30s) to `uploadFile()` so it *always* resolves/rejects.
- On timeout/abort, return `null` (or throw) so `attachUploadedFile()` shows an error message and `finishAttachmentWork()` runs.
- (Optional but recommended) expose a way to cancel pending attachments / clear the stuck state without switching threads.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Attachment errors get cleared🐞 Bug ✓ Correctness
Description
attachImageFile/attachUploadedFile unconditionally clear attachmentStatusText on any success,
which can erase an earlier error from another concurrently-attaching file. This can hide partial
attachment failures from the user.
Code

src/components/content/ThreadComposer.vue[R892-933]

+async function attachImageFile(file: File, sessionToken: number): Promise<void> {
+  if (!beginAttachmentWork(sessionToken)) return
+  try {
+    const normalizedFile = ensureFileName(file)
+    const dataUrl = await readFileAsDataUrl(normalizedFile)
+    if (sessionToken !== attachmentSessionToken) return
+    selectedImages.value = [
+      ...selectedImages.value,
+      {
+        id: createAttachmentId(),
+        name: normalizedFile.name,
+        url: dataUrl,
+      },
+    ]
+    attachmentStatusText.value = ''
+  } catch {
+    if (sessionToken === attachmentSessionToken) {
+      attachmentStatusText.value = 'Could not attach image.'
+    }
+  } finally {
+    finishAttachmentWork(sessionToken)
+  }
+}
+
+async function attachUploadedFile(file: File, sessionToken: number): Promise<void> {
+  if (!beginAttachmentWork(sessionToken)) return
+  try {
+    const serverPath = await uploadFile(file)
+    if (sessionToken !== attachmentSessionToken) return
+    if (!serverPath) {
+      attachmentStatusText.value = `Could not attach ${file.name || 'file'}.`
+      return
+    }
+    addFileAttachment(serverPath)
+    attachmentStatusText.value = ''
+  } catch {
+    if (sessionToken === attachmentSessionToken) {
+      attachmentStatusText.value = `Could not attach ${file.name || 'file'}.`
+    }
+  } finally {
+    finishAttachmentWork(sessionToken)
+  }
Evidence
The UI shows attachmentStatusText preferentially; failures set it to an error string, but any
later success clears it back to empty. With multiple concurrent attachments, this makes the
last-finishing success able to wipe out a prior failure message.

src/components/content/ThreadComposer.vue[557-564]
src/components/content/ThreadComposer.vue[892-913]
src/components/content/ThreadComposer.vue[916-933]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Attachment failure messaging can be lost because any successful attachment clears `attachmentStatusText`, potentially removing a failure message from another file that already failed.
### Issue Context
`attachmentFeedbackText` prefers `attachmentStatusText` over the pending counter text. Both `attachImageFile` and `attachUploadedFile` clear `attachmentStatusText` on success.
### Fix Focus Areas
- src/components/content/ThreadComposer.vue[557-564]
- src/components/content/ThreadComposer.vue[892-913]
- src/components/content/ThreadComposer.vue[916-933]
### Suggested fix approach
- Track errors separately (e.g., `attachmentErrors: string[]` or `{id,name,error}` list) and render them, or
- Only clear `attachmentStatusText` at the start of `attachIncomingFiles()` and never clear it from individual success callbacks, or
- Clear only when *all* pending attachments in the batch succeed (requires per-batch bookkeeping).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Drag overlay can stick 🐞 Bug ⛯ Reliability
Description
onInputDragLeave returns early unless hasFilePayload(event.dataTransfer) is true, so if
dragleave is received without a matching DataTransfer payload after dragenter activated the
overlay, dragDepth may not decrement to zero and isDragActive may remain true until a drop or
draft reset.
Code

src/components/content/ThreadComposer.vue[R1061-1067]

+function onInputDragLeave(event: DragEvent): void {
+  if (!hasFilePayload(event.dataTransfer)) return
+  event.preventDefault()
+  dragDepth = Math.max(0, dragDepth - 1)
+  if (dragDepth === 0) {
+    isDragActive.value = false
+  }
Evidence
The overlay state depends on dragDepth reaching 0, but dragDepth is only decremented on
dragleave events that satisfy hasFilePayload. If those events are filtered out, there is no
other local cleanup path besides drop/replaceDraftState that guarantees clearing isDragActive.

src/components/content/ThreadComposer.vue[953-956]
src/components/content/ThreadComposer.vue[1045-1068]
src/components/content/ThreadComposer.vue[1070-1076]
src/components/content/ThreadComposer.vue[611-618]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The drag overlay state is guarded by `hasFilePayload()` on dragleave, so `dragDepth` may not decrement and `isDragActive` may stay true in some drag-cancel/leave paths.
### Issue Context
`isDragActive` is set from drag events and cleared when `dragDepth` hits 0. `dragDepth` is decremented only if `hasFilePayload(event.dataTransfer)` passes.
### Fix Focus Areas
- src/components/content/ThreadComposer.vue[953-956]
- src/components/content/ThreadComposer.vue[1045-1068]
- src/components/content/ThreadComposer.vue[1070-1076]
### Suggested fix approach
- In `onInputDragLeave`, decrement/clear based on existing `isDragActive`/`dragDepth` rather than re-checking `dataTransfer`.
- Add a global safety reset (e.g., `window.addEventListener('dragend'|'drop'|'dragexit', ...)`) to clear `dragDepth/isDragActive`.
- Consider replacing `dragDepth` with a `relatedTarget` containment check to avoid counter drift.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Advisory comments

4. Image paste blocks text🐞 Bug ⚙ Maintainability
Description
When clipboard data includes any image file, onInputPaste calls preventDefault(), so mixed
clipboard content (text + image) will attach images but will not paste the text into the textarea.
Code

src/components/content/ThreadComposer.vue[R1032-1043]

+function onInputPaste(event: ClipboardEvent): void {
+  if (isInteractionDisabled.value) return
+  const items = Array.from(event.clipboardData?.items ?? [])
+  if (items.length === 0) return
+  const imageFiles = items
+    .filter((item) => item.kind === 'file' && item.type.startsWith('image/'))
+    .map((item) => item.getAsFile())
+    .filter((file): file is File => file instanceof File)
+  if (imageFiles.length === 0) return
+  event.preventDefault()
+  attachIncomingFiles(imageFiles)
+}
Evidence
The handler returns early only when there are no image files; otherwise it prevents the default
paste and attaches images, which blocks the browser's normal text insertion.

src/components/content/ThreadComposer.vue[1032-1043]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Pasting an image prevents the default paste entirely, so any accompanying clipboard text is not inserted.
### Issue Context
`onInputPaste` collects image clipboard items and calls `event.preventDefault()` whenever any are found.
### Fix Focus Areas
- src/components/content/ThreadComposer.vue[1032-1043]
### Suggested fix approach
- If clipboard contains text (`getData('text/plain')`) alongside images, either:
- do not call `preventDefault()` and still attach images asynchronously, or
- call `preventDefault()`, manually insert the text into `draft` at the cursor position, then attach images.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

ⓘ The new review experience is currently in Beta. Learn more

Grey Divider

Qodo Logo

@friuns2
Copy link
Copy Markdown
Owner

friuns2 commented Mar 28, 2026

Bug risk: composer submit can be blocked indefinitely when an attachment upload hangs.

canSubmit now returns false while pendingAttachmentCount > 0, but uploadFile() has no timeout/cancel path. If the network request never settles, finishAttachmentWork() never runs and the user cannot send any message.

Suggested fix: add AbortController timeout (e.g. 30s) in uploadFile, decrement pending count on timeout, and show retry feedback.

Refs:

  • const canSubmit = computed(() => {
    if (props.disabled) return false
    if (props.isUpdatingSpeedMode) return false
    if (!props.activeThreadId) return false
    if (pendingAttachmentCount.value > 0) return false
    return draft.value.trim().length > 0 || selectedImages.value.length > 0 || fileAttachments.value.length > 0
  • async function attachUploadedFile(file: File, sessionToken: number): Promise<void> {
    if (!beginAttachmentWork(sessionToken)) return
    try {
    const serverPath = await uploadFile(file)
    if (sessionToken !== attachmentSessionToken) return
    if (!serverPath) {
    attachmentStatusText.value = `Could not attach ${file.name || 'file'}.`
    return
    }
    addFileAttachment(serverPath)
    attachmentStatusText.value = ''
    } catch {
    if (sessionToken === attachmentSessionToken) {
    attachmentStatusText.value = `Could not attach ${file.name || 'file'}.`
    }
    } finally {
    finishAttachmentWork(sessionToken)
    }
  • https://github.com/friuns2/codexUI/blob/6b86b6e0b4ebbfa666a12a495334db4e22e6948c/src/api/codexGateway.ts#L915-L925

@friuns2
Copy link
Copy Markdown
Owner

friuns2 commented Mar 28, 2026

Bug risk: attachment failures in a batch can be silently hidden.

attachmentStatusText is cleared on each successful file attach. In a mixed-result batch (some fail, some succeed), a later success can erase earlier failure feedback and make missing attachments hard to detect.

Suggested fix: keep per-batch success/failure counts and render final status like 2 attached, 1 failed.

Refs:

@friuns2
Copy link
Copy Markdown
Owner

friuns2 commented Mar 28, 2026

Bug risk: drag overlay can stick in edge drag flows.

onInputDragLeave only decrements/reset when hasFilePayload(event.dataTransfer) is true. Some browser dragleave/drop-outside-window paths may not preserve Files type on leave events, which can leave isDragActive stuck true.

Suggested fix: add global fallback cleanup (window drop, dragend, blur) that force-resets dragDepth = 0 and isDragActive = false.

Refs:

SHAREN added a commit to SHAREN/codexUI that referenced this pull request Mar 28, 2026
What changed:
- added a 30s abort timeout to `uploadFile()` so stalled uploads resolve cleanly instead of leaving attachment work pending forever
- replaced per-file attachment status clearing with aggregate batch feedback so mixed success/failure results stay visible to the user
- kept plain-text paste behavior when clipboard data contains both text and images, while still attaching pasted images
- added drag-state reset helpers plus window-level cleanup for drag cancel and drop-outside-window flows
- documented manual verification steps for these composer fixes in `tests.md`

Why:
- PR friuns2#20 review found several composer regressions around hung uploads, hidden partial failures, sticky drag overlays, and mixed clipboard paste

Impact:
- composer submit recovers after timed-out uploads
- users can see partial attachment failures instead of having them erased by later successes
- drag overlay no longer sticks in edge flows
- image paste keeps accompanying plain text

Validation:
- npm install
- npm run build
@SHAREN
Copy link
Copy Markdown
Contributor Author

SHAREN commented Mar 28, 2026

Addressed the attachment/composer review items in the PR head branch and rebased the branch onto the latest main to clear the merge conflict.

Current commits in the PR head branch:

  • b3f4419 Fix PR20 composer attachment review regressions
  • 7ce71fe Increase PR20 attachment upload timeout to 60 seconds

What changed:

  • added AbortController timeout handling to uploadFile() so stuck uploads no longer leave pendingAttachmentCount blocking submit forever
  • switched attachment feedback to batch success/failure accounting so mixed outcomes stay visible instead of being cleared by a later success
  • added drag cleanup/reset handling for dragleave edge cases plus window-level drop / dragend / blur fallback cleanup
  • kept plain-text paste behavior when clipboard data contains both text and images while still attaching pasted images
  • merged the PR20 manual verification steps into the repository tests.md format during the rebase conflict resolution

Validation:

  • npm run build

SHAREN added 3 commits March 29, 2026 03:58
What changed:
- added a shared composer attachment pipeline for picker, drag-and-drop, and clipboard image paste
- blocked submit while attachment work is pending and surfaced attachment feedback in the composer
- added drag-active overlay styling plus dark-theme overrides for the new composer states

Why:
- upstream codexUI did not yet include the drag-and-drop file flow or Ctrl+V image paste behavior already proven in the local codexui repo
- the dark theme needed the follow-up overlay treatment from 2e28ea1 so the new drag state would remain readable

Impact:
- users can drop files directly onto the composer, paste screenshots with Ctrl+V, and keep normal plain-text paste behavior
- dark mode shows the drag overlay with matching colors instead of a light-theme flash

Validation:
- npm run build
- headless Playwright CLI checks for file drop, image drop, image paste, plain-text paste passthrough, and dark overlay screenshot
What changed:
- added a 30s abort timeout to `uploadFile()` so stalled uploads resolve cleanly instead of leaving attachment work pending forever
- replaced per-file attachment status clearing with aggregate batch feedback so mixed success/failure results stay visible to the user
- kept plain-text paste behavior when clipboard data contains both text and images, while still attaching pasted images
- added drag-state reset helpers plus window-level cleanup for drag cancel and drop-outside-window flows
- documented manual verification steps for these composer fixes in `tests.md`

Why:
- PR friuns2#20 review found several composer regressions around hung uploads, hidden partial failures, sticky drag overlays, and mixed clipboard paste

Impact:
- composer submit recovers after timed-out uploads
- users can see partial attachment failures instead of having them erased by later successes
- drag overlay no longer sticks in edge flows
- image paste keeps accompanying plain text

Validation:
- npm install
- npm run build

# Conflicts:
#	tests.md
What changed:
- raised the shared attachment upload abort timeout from 30 seconds to 60 seconds
- updated the manual test notes to mention the 60 second timeout path

Why:
- 30 seconds is aggressive for UI file uploads and can create false failures on slower networks

Impact:
- composer attachment uploads get more time to complete before timing out
- manual verification notes match the implemented timeout

Validation:
- npm run build

# Conflicts:
#	tests.md
@SHAREN SHAREN force-pushed the codex/composer-drag-paste branch from d70b177 to 7ce71fe Compare March 28, 2026 23:06
@friuns2
Copy link
Copy Markdown
Owner

friuns2 commented Mar 29, 2026

ты все протестировал еше раз)?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants