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 { 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 canSend = computed(() =>
  !!chat.currentId && !chat.streaming &&
  (draft.value.trim() || chat.pendingImages.length || chat.pendingFiles.length)
)

chat.onDraftRestore((saved) => {
  draft.value = saved
  nextTick(autoResize)
})

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

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) return

  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)
}

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>