diff --git a/app/components/molecules/ChatInput.vue b/app/components/molecules/ChatInput.vue index 5582c26..c98d06d 100644 --- a/app/components/molecules/ChatInput.vue +++ b/app/components/molecules/ChatInput.vue @@ -17,6 +17,7 @@ const emit = defineEmits<{ }>() const { t } = useContent() +const toast = useToast() // "Upload to media library" is an ee/media-gated capability; hidden otherwise. const canUploadMedia = useFeature('media.upload') // The picker lives in the composer's action strip; the command palette writes @@ -40,7 +41,10 @@ const pickerAccept = computed(() => pendingIntent.value === 'media' ? IMAGE_ACCE const URL_RE = /^https?:\/\/\S+$/i const hasUploading = computed(() => attachments.value.some(a => a.status === 'uploading')) -const canSend = computed(() => !!input.value.trim() && !props.disabled && !hasUploading.value) +// A failed attachment blocks sending until it is removed — otherwise the +// message would silently go out without the file the user believes is attached. +const hasErrored = computed(() => attachments.value.some(a => a.status === 'error')) +const canSend = computed(() => !!input.value.trim() && !props.disabled && !props.streaming && !hasUploading.value && !hasErrored.value) interface ServerRef { id: string @@ -77,15 +81,23 @@ function formatBytes(bytes?: number): string { return `${(bytes / (1024 * 1024)).toFixed(1)} MB` } +/** + * Mark an attachment failed AND say so out loud. The red chip alone proved + * too quiet — users sent messages believing the file was attached. + */ +function failAttachment(att: UIAttachment, message: string) { + att.status = 'error' + att.error = message + toast.error(message) +} + function applyRef(att: UIAttachment, ref: ServerRef | undefined) { if (!ref) { - att.status = 'error' - att.error = t('chat.attachment_failed') + failAttachment(att, t('chat.attachment_failed')) return } if (ref.error) { - att.status = 'error' - att.error = ref.error + failAttachment(att, ref.error) return } att.status = 'ready' @@ -121,8 +133,7 @@ async function uploadFile(file: File, intent: 'context' | 'media') { applyRef(att, res.attachments?.[0]) } catch (e) { - att.status = 'error' - att.error = resolveApiError(e, t('chat.attachment_failed')) + failAttachment(att, resolveApiError(e, t('chat.attachment_failed'))) } } @@ -144,8 +155,7 @@ async function attachLink(url: string) { applyRef(att, res.attachments?.[0]) } catch (e) { - att.status = 'error' - att.error = resolveApiError(e, t('chat.attachment_failed')) + failAttachment(att, resolveApiError(e, t('chat.attachment_failed'))) } } @@ -158,7 +168,7 @@ function handleFiles(files: FileList | File[] | null | undefined, intent: 'conte function addFiles(files: FileList | File[] | null) { handleFiles(files, 'context') } -defineExpose({ addFiles }) +defineExpose({ addFiles, attachLink }) function openPicker(intent: 'context' | 'media') { pendingIntent.value = intent @@ -196,6 +206,17 @@ function onPaste(e: ClipboardEvent) { handleFiles(dt.files, 'context') return } + // Some clipboard sources expose the file only through `items`, never + // `files` — without this fallback the paste was silently discarded. + const itemFiles = Array.from(dt.items ?? []) + .filter(item => item.kind === 'file') + .map(item => item.getAsFile()) + .filter((f): f is File => f !== null) + if (itemFiles.length > 0) { + e.preventDefault() + handleFiles(itemFiles, 'context') + return + } // A clipboard whose entire text is a single URL → attach as a link. const text = dt.getData('text')?.trim() if (text && URL_RE.test(text)) { @@ -212,12 +233,39 @@ function attachmentIcon(att: UIAttachment): string { } function handleSend() { + // Mirrors `canSend` exactly. The streaming check matters for Enter: the + // send button is swapped for stop while streaming, but the keydown path + // used to fall through, wipe the composer + tray, and send nothing + // (useChat's isStreaming guard rejected it downstream). const text = input.value.trim() - if (!text || props.disabled || hasUploading.value) return + if (!text || props.disabled || props.streaming || hasUploading.value || hasErrored.value) return emit('send', text, attachments.value) input.value = '' attachments.value = [] - nextTick(() => autoResize()) + nextTick(() => { + autoResize() + textareaRef.value?.focus() + }) +} + +/** + * The card is styled as one big input, but only the inner textarea accepts + * typing/paste — and nothing used to focus it, so a paste after clicking the + * card's padding landed on and vanished. Clicks on interactive + * children keep their own focus. + */ +function focusComposer(e: MouseEvent) { + const target = e.target as HTMLElement | null + if (target?.closest('button, a, input, textarea, select, [role="menuitem"]')) return + textareaRef.value?.focus() +} + +/** Radix returns focus to the trigger on close; hand it back to the textarea. */ +function onAttachMenuToggle(open: boolean) { + if (open) return + nextTick(() => { + if (!showLinkInput.value) textareaRef.value?.focus() + }) } function handleKeydown(e: KeyboardEvent) { @@ -248,6 +296,7 @@ function autoResize() { the whole card lights up as a unit. -->