Newer
Older
navi-1 / webclient / src / components / chat / InputBar.vue
<template>
  <div
    class="input-bar input-dropzone"
    :class="{ 'is-dragging': isDragging }"
    @dragenter="handleDragEnter"
    @dragover="handleDragOver"
    @dragleave="handleDragLeave"
    @drop="handleDrop"
  >
    <!-- File previews -->
    <FilePreviewStrip
      v-if="chat.pendingImages.length || chat.pendingFiles.length"
    />

    <!-- Upload progress -->
    <div v-if="uploading" class="upload-progress">
      <div class="upload-progress-fill" style="width: 60%" />
    </div>

    <!-- Reconnect warning -->
    <div v-if="props.ws.reconnectFailed.value" class="reconnect-notice">
      <i class="ph ph-warning"></i>
      Connection lost — retrying in background
    </div>

    <div class="input-row">
      <input
        ref="fileInputEl"
        type="file"
        multiple
        style="display:none"
        @change="handleFileInput"
      />

      <button
        class="btn-icon"
        title="Attach files"
        :disabled="!chat.currentId"
        @click="fileInputEl.click()"
      >
        <i class="ph ph-paperclip"></i>
      </button>

      <textarea
        ref="textareaEl"
        v-model="draft"
        class="input-textarea"
        placeholder="Message Navi…"
        rows="1"
        :disabled="!chat.currentId"
        @keydown.enter.exact.prevent="handleSend"
        @keydown.ctrl.enter.prevent="insertNewline"
        @input="autoResize"
        @paste="handlePaste"
      />

      <button
        v-if="chat.streaming"
        class="btn-icon"
        title="Stop generation"
        @click="handleStop"
      >
        <i class="ph ph-stop-circle"></i>
      </button>

      <button
        v-else
        class="btn-icon"
        title="Send (Enter)"
        :disabled="!canSend"
        @click="handleSend"
      >
        <i class="ph ph-paper-plane-tilt"></i>
      </button>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, watch, nextTick } from 'vue'
import { useChatStore } from '@/stores/chat.js'
import { useFileUpload } from '@/composables/useFileUpload.js'
import { useQuoteReply } from '@/composables/useQuoteReply.js'
import { stopSession } from '@/api/index.js'
import FilePreviewStrip from './FilePreviewStrip.vue'

const props = defineProps({
  ws: { type: Object, required: true }
})

const chat = useChatStore()

// Destructure so refs are top-level → auto-unwrapped in template
const {
  isDragging, uploading,
  handleFileInput, handlePaste,
  handleDragEnter, handleDragOver, handleDragLeave, handleDrop,
  clearPending
} = useFileUpload()

const draft = ref('')
const textareaEl = ref(null)
const fileInputEl = ref(null)
const sending = ref(false)

const canSend = computed(() =>
  !!chat.currentId && !chat.streaming && !sending.value &&
  (draft.value.trim() || chat.pendingImages.length || chat.pendingFiles.length)
)

// Restore draft whenever the active session changes (covers refresh + session switching)
watch(() => chat.currentId, (id) => {
  draft.value = id ? chat.loadDraft(id) : ''
  nextTick(autoResize)
}, { immediate: true })

watch(draft, (val) => chat.saveDraft(val))

// Quote reply — insert citation prefix and focus textarea
const { pendingQuote, consumeQuote } = useQuoteReply()
watch(pendingQuote, (val) => {
  if (!val) return
  const quoted = val.split('\n').map(line => `> ${line}`).join('\n')
  draft.value = quoted + '\n\n' + draft.value
  consumeQuote()
  nextTick(() => {
    autoResize()
    textareaEl.value?.focus()
    // Move cursor to end of draft (after the quote block)
    const el = textareaEl.value
    if (el) el.selectionStart = el.selectionEnd = el.value.length
  })
})

function autoResize() {
  const el = textareaEl.value
  if (!el) return
  el.style.height = 'auto'
  el.style.height = Math.min(el.scrollHeight, 144) + 'px'
}

function handleSend() {
  if (!canSend.value || sending.value) return
  sending.value = true

  try {
    const text = draft.value.trim()
    const images = [...chat.pendingImages]
    const files = [...chat.pendingFiles]

    chat.appendUserMessage(text, images, files)
    clearPending()
    draft.value = ''
    nextTick(autoResize)

    // Strip data URL prefix — server expects raw base64
    const b64Images = images.map(d => d.split(',', 2)[1]).filter(Boolean)

    const payload = { type: 'message', content: text || ' ' }
    if (b64Images.length) payload.images = b64Images
    if (files.length) payload.files = files
    props.ws.send(payload)
  } finally {
    sending.value = false
  }
}

function insertNewline() {
  const el = textareaEl.value
  if (!el) return
  const start = el.selectionStart
  const end = el.selectionEnd
  draft.value = draft.value.slice(0, start) + '\n' + draft.value.slice(end)
  nextTick(() => {
    el.selectionStart = el.selectionEnd = start + 1
    autoResize()
  })
}

async function handleStop() {
  if (!chat.currentId) return
  await stopSession(chat.currentId)
}
</script>

<style scoped>
.reconnect-notice {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 4px 8px;
  font-size: 12px;
  color: var(--color-warning, #e0af68);
  margin-bottom: 4px;
}
</style>