Skip to content

Commit 6b86b6e

Browse files
committed
Add composer drag, paste, and dark overlay support
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
1 parent 6e9f55e commit 6b86b6e

2 files changed

Lines changed: 254 additions & 23 deletions

File tree

src/components/content/ThreadComposer.vue

Lines changed: 230 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,13 @@
44
{{ dictationErrorText }}
55
</p>
66

7-
<div class="thread-composer-shell" :class="{ 'thread-composer-shell--no-top-radius': hasQueueAbove }">
7+
<div
8+
class="thread-composer-shell"
9+
:class="{
10+
'thread-composer-shell--no-top-radius': hasQueueAbove,
11+
'thread-composer-shell--drag-active': isDragActive,
12+
}"
13+
>
814
<div v-if="selectedImages.length > 0" class="thread-composer-attachments">
915
<div v-for="image in selectedImages" :key="image.id" class="thread-composer-attachment">
1016
<img class="thread-composer-attachment-image" :src="image.url" :alt="image.name || 'Selected image'" />
@@ -68,7 +74,17 @@
6874
</span>
6975
</div>
7076

71-
<div class="thread-composer-input-wrap">
77+
<div
78+
class="thread-composer-input-wrap"
79+
:class="{ 'thread-composer-input-wrap--drag-active': isDragActive }"
80+
@dragenter="onInputDragEnter"
81+
@dragover="onInputDragOver"
82+
@dragleave="onInputDragLeave"
83+
@drop="onInputDrop"
84+
>
85+
<div v-if="isDragActive" class="thread-composer-drop-overlay" aria-hidden="true">
86+
<span class="thread-composer-drop-overlay-copy">Drop images or files</span>
87+
</div>
7288
<div v-if="isFileMentionOpen" class="thread-composer-file-mentions">
7389
<template v-if="fileMentionSuggestions.length > 0">
7490
<button
@@ -104,6 +120,7 @@
104120
:disabled="isInteractionDisabled"
105121
@input="onInputChange"
106122
@keydown="onInputKeydown"
123+
@paste="onInputPaste"
107124
/>
108125
<ComposerSkillPicker
109126
:skills="skillOptions"
@@ -276,6 +293,9 @@
276293
</div>
277294

278295
</div>
296+
<p v-if="!dictationErrorText && attachmentFeedbackText" class="thread-composer-attachment-feedback">
297+
{{ attachmentFeedbackText }}
298+
</p>
279299
<input
280300
ref="photoLibraryInputRef"
281301
class="thread-composer-hidden-input"
@@ -397,6 +417,9 @@ const fileAttachments = ref<FileAttachment[]>([])
397417
const folderUploadGroups = ref<FolderUploadGroup[]>([])
398418
399419
const dictationFeedback = ref('')
420+
const attachmentStatusText = ref('')
421+
const pendingAttachmentCount = ref(0)
422+
const isDragActive = ref(false)
400423
const {
401424
state: dictationState,
402425
isSupported: isDictationSupported,
@@ -451,6 +474,8 @@ let fileMentionSearchToken = 0
451474
let fileMentionDebounceTimer: ReturnType<typeof setTimeout> | null = null
452475
let isHoldPressActive = false
453476
let dictationShouldRollbackLatestUserTurn = false
477+
let dragDepth = 0
478+
let attachmentSessionToken = 0
454479
const isAndroid = typeof navigator !== 'undefined' && /Android/i.test(navigator.userAgent)
455480
const DRAFT_STORAGE_PREFIX = 'codex-web-local.thread-draft.v1.'
456481
let lastActiveThreadId = ''
@@ -485,6 +510,7 @@ const canSubmit = computed(() => {
485510
if (props.disabled) return false
486511
if (props.isUpdatingSpeedMode) return false
487512
if (!props.activeThreadId) return false
513+
if (pendingAttachmentCount.value > 0) return false
488514
return draft.value.trim().length > 0 || selectedImages.value.length > 0 || fileAttachments.value.length > 0
489515
})
490516
const hasUnsavedDraft = computed(() =>
@@ -528,6 +554,14 @@ const dictationButtonLabel = computed(() => {
528554
const dictationErrorText = computed(() =>
529555
dictationState.value === 'idle' ? dictationFeedback.value.trim() : '',
530556
)
557+
const attachmentFeedbackText = computed(() => {
558+
const explicit = attachmentStatusText.value.trim()
559+
if (explicit) return explicit
560+
if (pendingAttachmentCount.value <= 0) return ''
561+
return pendingAttachmentCount.value === 1
562+
? 'Attaching file...'
563+
: `Attaching ${pendingAttachmentCount.value} files...`
564+
})
531565
const dictationDurationLabel = computed(() => {
532566
const totalSeconds = Math.max(0, Math.floor(recordingDurationMs.value / 1000))
533567
const minutes = Math.floor(totalSeconds / 60)
@@ -574,9 +608,14 @@ function replaceDraftState(payload: ComposerDraftPayload): void {
574608
fileAttachments.value = payload.fileAttachments.map((attachment) => ({ ...attachment }))
575609
folderUploadGroups.value = []
576610
dictationFeedback.value = ''
611+
attachmentStatusText.value = ''
612+
pendingAttachmentCount.value = 0
613+
isDragActive.value = false
577614
isAttachMenuOpen.value = false
578615
isSlashMenuOpen.value = false
579616
closeFileMention()
617+
dragDepth = 0
618+
attachmentSessionToken += 1
580619
}
581620
582621
function clearDraftState(): void {
@@ -789,31 +828,133 @@ function isImageFile(file: File): boolean {
789828
return /\.(png|jpe?g|gif|webp)$/i.test(file.name)
790829
}
791830
792-
function addFiles(files: FileList | null): void {
793-
if (!files || files.length === 0) return
794-
const generation = draftGeneration.value
795-
for (const file of Array.from(files)) {
796-
if (isImageFile(file)) {
797-
const reader = new FileReader()
798-
reader.onload = () => {
799-
if (generation !== draftGeneration.value) return
800-
if (typeof reader.result !== 'string') return
801-
selectedImages.value.push({
802-
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
803-
name: file.name,
804-
url: reader.result,
805-
})
831+
function normalizeSelectedFiles(files: FileList | File[] | null | undefined): File[] {
832+
if (!files) return []
833+
return Array.from(files)
834+
}
835+
836+
function beginAttachmentWork(sessionToken: number): boolean {
837+
if (sessionToken !== attachmentSessionToken) return false
838+
pendingAttachmentCount.value += 1
839+
return true
840+
}
841+
842+
function finishAttachmentWork(sessionToken: number): void {
843+
if (sessionToken !== attachmentSessionToken) return
844+
pendingAttachmentCount.value = Math.max(0, pendingAttachmentCount.value - 1)
845+
}
846+
847+
function createAttachmentId(): string {
848+
return `${Date.now()}-${Math.random().toString(36).slice(2)}`
849+
}
850+
851+
function createPastedImageName(file: File): string {
852+
const now = new Date()
853+
const timestamp = [
854+
now.getFullYear(),
855+
String(now.getMonth() + 1).padStart(2, '0'),
856+
String(now.getDate()).padStart(2, '0'),
857+
String(now.getHours()).padStart(2, '0'),
858+
String(now.getMinutes()).padStart(2, '0'),
859+
String(now.getSeconds()).padStart(2, '0'),
860+
].join('-')
861+
const ext = file.type.startsWith('image/')
862+
? file.type.slice('image/'.length).replace(/[^a-z0-9]+/gi, '') || 'png'
863+
: 'png'
864+
return `pasted-image-${timestamp}.${ext}`
865+
}
866+
867+
function ensureFileName(file: File): File {
868+
if (file.name.trim()) return file
869+
return new File([file], createPastedImageName(file), {
870+
type: file.type || 'image/png',
871+
lastModified: Date.now(),
872+
})
873+
}
874+
875+
function readFileAsDataUrl(file: File): Promise<string> {
876+
return new Promise((resolve, reject) => {
877+
const reader = new FileReader()
878+
reader.onload = () => {
879+
if (typeof reader.result === 'string') {
880+
resolve(reader.result)
881+
return
806882
}
807-
reader.readAsDataURL(file)
883+
reject(new Error('Image read returned an unsupported result'))
884+
}
885+
reader.onerror = () => {
886+
reject(reader.error ?? new Error('Image read failed'))
887+
}
888+
reader.readAsDataURL(file)
889+
})
890+
}
891+
892+
async function attachImageFile(file: File, sessionToken: number): Promise<void> {
893+
if (!beginAttachmentWork(sessionToken)) return
894+
try {
895+
const normalizedFile = ensureFileName(file)
896+
const dataUrl = await readFileAsDataUrl(normalizedFile)
897+
if (sessionToken !== attachmentSessionToken) return
898+
selectedImages.value = [
899+
...selectedImages.value,
900+
{
901+
id: createAttachmentId(),
902+
name: normalizedFile.name,
903+
url: dataUrl,
904+
},
905+
]
906+
attachmentStatusText.value = ''
907+
} catch {
908+
if (sessionToken === attachmentSessionToken) {
909+
attachmentStatusText.value = 'Could not attach image.'
910+
}
911+
} finally {
912+
finishAttachmentWork(sessionToken)
913+
}
914+
}
915+
916+
async function attachUploadedFile(file: File, sessionToken: number): Promise<void> {
917+
if (!beginAttachmentWork(sessionToken)) return
918+
try {
919+
const serverPath = await uploadFile(file)
920+
if (sessionToken !== attachmentSessionToken) return
921+
if (!serverPath) {
922+
attachmentStatusText.value = `Could not attach ${file.name || 'file'}.`
923+
return
924+
}
925+
addFileAttachment(serverPath)
926+
attachmentStatusText.value = ''
927+
} catch {
928+
if (sessionToken === attachmentSessionToken) {
929+
attachmentStatusText.value = `Could not attach ${file.name || 'file'}.`
930+
}
931+
} finally {
932+
finishAttachmentWork(sessionToken)
933+
}
934+
}
935+
936+
function attachIncomingFiles(files: FileList | File[] | null | undefined): void {
937+
const normalizedFiles = normalizeSelectedFiles(files)
938+
if (normalizedFiles.length === 0) return
939+
attachmentStatusText.value = ''
940+
isAttachMenuOpen.value = false
941+
isSlashMenuOpen.value = false
942+
closeFileMention()
943+
const sessionToken = attachmentSessionToken
944+
for (const file of normalizedFiles) {
945+
if (isImageFile(file)) {
946+
void attachImageFile(file, sessionToken)
808947
} else {
809-
void uploadFile(file).then((serverPath) => {
810-
if (generation !== draftGeneration.value) return
811-
if (serverPath) addFileAttachment(serverPath)
812-
}).catch(() => {})
948+
void attachUploadedFile(file, sessionToken)
813949
}
814950
}
815951
}
816952
953+
function hasFilePayload(dataTransfer: DataTransfer | null): boolean {
954+
if (!dataTransfer) return false
955+
return Array.from(dataTransfer.types ?? []).includes('Files')
956+
}
957+
817958
async function addFolderFiles(files: FileList | null): Promise<void> {
818959
if (!files || files.length === 0) return
819960
const generation = draftGeneration.value
@@ -869,14 +1010,14 @@ function clearInputValue(inputRefEl: HTMLInputElement | null): void {
8691010
8701011
function onPhotoLibraryChange(event: Event): void {
8711012
const input = event.target as HTMLInputElement | null
872-
addFiles(input?.files ?? null)
1013+
attachIncomingFiles(input?.files ?? null)
8731014
clearInputValue(input)
8741015
isAttachMenuOpen.value = false
8751016
}
8761017
8771018
function onCameraCaptureChange(event: Event): void {
8781019
const input = event.target as HTMLInputElement | null
879-
addFiles(input?.files ?? null)
1020+
attachIncomingFiles(input?.files ?? null)
8801021
clearInputValue(input)
8811022
isAttachMenuOpen.value = false
8821023
}
@@ -888,6 +1029,52 @@ function onFolderPickerChange(event: Event): void {
8881029
isAttachMenuOpen.value = false
8891030
}
8901031
1032+
function onInputPaste(event: ClipboardEvent): void {
1033+
if (isInteractionDisabled.value) return
1034+
const items = Array.from(event.clipboardData?.items ?? [])
1035+
if (items.length === 0) return
1036+
const imageFiles = items
1037+
.filter((item) => item.kind === 'file' && item.type.startsWith('image/'))
1038+
.map((item) => item.getAsFile())
1039+
.filter((file): file is File => file instanceof File)
1040+
if (imageFiles.length === 0) return
1041+
event.preventDefault()
1042+
attachIncomingFiles(imageFiles)
1043+
}
1044+
1045+
function onInputDragEnter(event: DragEvent): void {
1046+
if (isInteractionDisabled.value || !hasFilePayload(event.dataTransfer)) return
1047+
event.preventDefault()
1048+
dragDepth += 1
1049+
isDragActive.value = true
1050+
}
1051+
1052+
function onInputDragOver(event: DragEvent): void {
1053+
if (isInteractionDisabled.value || !hasFilePayload(event.dataTransfer)) return
1054+
event.preventDefault()
1055+
if (event.dataTransfer) {
1056+
event.dataTransfer.dropEffect = 'copy'
1057+
}
1058+
isDragActive.value = true
1059+
}
1060+
1061+
function onInputDragLeave(event: DragEvent): void {
1062+
if (!hasFilePayload(event.dataTransfer)) return
1063+
event.preventDefault()
1064+
dragDepth = Math.max(0, dragDepth - 1)
1065+
if (dragDepth === 0) {
1066+
isDragActive.value = false
1067+
}
1068+
}
1069+
1070+
function onInputDrop(event: DragEvent): void {
1071+
if (isInteractionDisabled.value || !hasFilePayload(event.dataTransfer)) return
1072+
event.preventDefault()
1073+
dragDepth = 0
1074+
isDragActive.value = false
1075+
attachIncomingFiles(event.dataTransfer?.files ?? null)
1076+
}
1077+
8911078
function onInputChange(): void {
8921079
if (dictationFeedback.value) {
8931080
dictationFeedback.value = ''
@@ -1180,6 +1367,10 @@ watch(
11801367
@apply relative rounded-2xl border border-zinc-300 bg-white p-2 sm:p-3 shadow-sm;
11811368
}
11821369
1370+
.thread-composer-shell--drag-active {
1371+
@apply border-zinc-900 shadow-md;
1372+
}
1373+
11831374
.thread-composer-shell--no-top-radius {
11841375
@apply rounded-t-none border-t-0;
11851376
}
@@ -1264,6 +1455,18 @@ watch(
12641455
@apply relative;
12651456
}
12661457
1458+
.thread-composer-input-wrap--drag-active {
1459+
@apply rounded-xl bg-zinc-50;
1460+
}
1461+
1462+
.thread-composer-drop-overlay {
1463+
@apply pointer-events-none absolute inset-0 z-30 flex items-center justify-center rounded-xl border border-dashed border-zinc-900 bg-white/90;
1464+
}
1465+
1466+
.thread-composer-drop-overlay-copy {
1467+
@apply rounded-full bg-zinc-900 px-3 py-1 text-xs font-medium text-white shadow-sm;
1468+
}
1469+
12671470
.thread-composer-file-mentions {
12681471
@apply absolute left-0 right-0 bottom-[calc(100%+8px)] z-40 max-h-52 overflow-y-auto rounded-xl border border-zinc-200 bg-white p-1 shadow-lg;
12691472
}
@@ -1443,6 +1646,10 @@ watch(
14431646
@apply mb-2 px-1 text-xs text-amber-700;
14441647
}
14451648
1649+
.thread-composer-attachment-feedback {
1650+
@apply mt-2 px-1 text-xs text-zinc-500;
1651+
}
1652+
14461653
.thread-composer-submit {
14471654
@apply inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full border-0 bg-zinc-900 text-white transition hover:bg-black disabled:cursor-not-allowed disabled:bg-zinc-200 disabled:text-zinc-500;
14481655
}

0 commit comments

Comments
 (0)