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'" />
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
104120 :disabled =" isInteractionDisabled"
105121 @input =" onInputChange"
106122 @keydown =" onInputKeydown"
123+ @paste =" onInputPaste"
107124 />
108125 <ComposerSkillPicker
109126 :skills =" skillOptions"
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[]>([])
397417const folderUploadGroups = ref <FolderUploadGroup []>([])
398418
399419const dictationFeedback = ref (' ' )
420+ const attachmentStatusText = ref (' ' )
421+ const pendingAttachmentCount = ref (0 )
422+ const isDragActive = ref (false )
400423const {
401424 state : dictationState,
402425 isSupported : isDictationSupported,
@@ -451,6 +474,8 @@ let fileMentionSearchToken = 0
451474let fileMentionDebounceTimer: ReturnType <typeof setTimeout > | null = null
452475let isHoldPressActive = false
453476let dictationShouldRollbackLatestUserTurn = false
477+ let dragDepth = 0
478+ let attachmentSessionToken = 0
454479const isAndroid = typeof navigator !== ' undefined' && / Android/ i .test (navigator .userAgent )
455480const DRAFT_STORAGE_PREFIX = ' codex-web-local.thread-draft.v1.'
456481let 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})
490516const hasUnsavedDraft = computed (() =>
@@ -528,6 +554,14 @@ const dictationButtonLabel = computed(() => {
528554const 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+ })
531565const 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
582621function 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+
817958async 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
8701011function 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
8771018function 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+
8911078function 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