openpencil/src/components/ChatPanel.vue
Anton A S 54a418f555 Block chat input during AI streaming to prevent message spam
Input is now disabled during both 'submitted' and 'streaming' states.
handleSubmit guards against concurrent sends.
2026-03-13 10:17:49 +03:00

145 lines
4.8 KiB
Vue

<script setup lang="ts">
import { ScrollAreaRoot, ScrollAreaScrollbar, ScrollAreaThumb, ScrollAreaViewport } from 'reka-ui'
import { computed, markRaw, nextTick, ref, watch } from 'vue'
import { copyChatLog } from '@/ai/chat-debug'
import { clearToolLogEntries } from '@/ai/tools'
import ChatInput from '@/components/chat/ChatInput.vue'
import ChatMessage from '@/components/chat/ChatMessage.vue'
import ProviderSetup from '@/components/chat/ProviderSetup.vue'
import { useAIChat } from '@/composables/use-chat'
import type { Chat } from '@ai-sdk/vue'
import type { UIMessage } from 'ai'
const IS_DEV = import.meta.env.DEV
const { isConfigured, ensureChat, resetChat } = useAIChat()
const existing = ensureChat()
const chat = ref<Chat<UIMessage> | null>(existing ? markRaw(existing) : null)
const messagesEnd = ref<HTMLDivElement>()
const debugCopied = ref(false)
const messages = computed(() => chat.value?.messages ?? [])
const status = computed(() => chat.value?.status ?? 'ready')
function scrollToBottom() {
nextTick(() => {
messagesEnd.value?.scrollIntoView({ behavior: 'smooth', block: 'end' })
})
}
watch(messages, scrollToBottom, { deep: true })
function handleSubmit(text: string) {
if (status.value === 'streaming' || status.value === 'submitted') return
const c = ensureChat()
if (c) chat.value = markRaw(c)
chat.value?.sendMessage({ text }).catch(() => {
/* user-facing error handled by UI */
})
}
function handleStop() {
chat.value?.stop()
}
async function handleCopyDebug() {
await copyChatLog(messages.value)
debugCopied.value = true
setTimeout(() => {
debugCopied.value = false
}, 1500)
}
function handleClearChat() {
chat.value = null
resetChat()
clearToolLogEntries()
}
</script>
<template>
<div data-test-id="chat-panel" class="flex min-w-0 flex-1 flex-col overflow-hidden select-text">
<ProviderSetup v-if="!isConfigured" />
<template v-else>
<ScrollAreaRoot class="min-h-0 flex-1">
<ScrollAreaViewport class="h-full px-3 py-3 [&>div]:h-full">
<!-- Empty state -->
<div
v-if="messages.length === 0"
data-test-id="chat-empty-state"
class="flex h-full flex-col items-center justify-center gap-3 text-muted"
>
<icon-lucide-message-circle class="size-8 opacity-50" />
<p class="text-center text-xs">Describe what you want to create or change.</p>
</div>
<!-- Messages -->
<div v-else data-test-id="chat-messages" class="flex flex-col gap-3">
<ChatMessage v-for="msg in messages" :key="msg.id" :message="msg" />
<!-- Typing indicator -->
<div
v-if="status === 'submitted'"
data-test-id="chat-typing-indicator"
class="flex gap-2"
>
<div
class="flex size-6 shrink-0 items-center justify-center rounded-full bg-muted/20 text-[10px] font-bold text-muted"
>
AI
</div>
<div class="flex items-center gap-1 py-2">
<span
class="size-1.5 animate-bounce rounded-full bg-muted"
style="animation-delay: 0ms"
/>
<span
class="size-1.5 animate-bounce rounded-full bg-muted"
style="animation-delay: 150ms"
/>
<span
class="size-1.5 animate-bounce rounded-full bg-muted"
style="animation-delay: 300ms"
/>
</div>
</div>
<div ref="messagesEnd" />
</div>
</ScrollAreaViewport>
<ScrollAreaScrollbar orientation="vertical" class="flex w-1.5 touch-none p-px select-none">
<ScrollAreaThumb class="relative flex-1 rounded-full bg-muted/30" />
</ScrollAreaScrollbar>
</ScrollAreaRoot>
<!-- Debug toolbar (dev only) -->
<div
v-if="IS_DEV && messages.length > 0"
class="flex shrink-0 items-center gap-1 border-t border-border px-3 py-1"
>
<button
class="flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] text-muted hover:bg-hover hover:text-surface"
@click="handleCopyDebug"
>
<icon-lucide-clipboard-copy v-if="!debugCopied" class="size-3" />
<icon-lucide-check v-else class="size-3 text-green-400" />
{{ debugCopied ? 'Copied' : 'Copy log' }}
</button>
<button
class="flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] text-muted hover:bg-hover hover:text-surface"
@click="handleClearChat"
>
<icon-lucide-trash-2 class="size-3" />
Clear
</button>
</div>
<ChatInput :status="status" @submit="handleSubmit" @stop="handleStop" />
</template>
</div>
</template>