diff --git a/src/components/ChatPanel.vue b/src/components/ChatPanel.vue index 193c636..5ca174b 100644 --- a/src/components/ChatPanel.vue +++ b/src/components/ChatPanel.vue @@ -26,8 +26,11 @@ class="chat-input" placeholder="Ask Robin something..." @keydown.enter="send" + :disabled="thinking" /> - + @@ -42,13 +45,12 @@ interface RobinEvent { pattern_id: string; title: string; body: string; severity const messages = ref([]) const input = ref('') +const thinking = ref(false) const messagesEl = ref(null) let unlisten: UnlistenFn | null = null +let activeCleanup: (() => void) | null = null onMounted(async () => { - // Signal backend: stop queuing events into PENDING while panel is open. - // Must happen before drain so new events that arrive during mount are not - // double-delivered on the next open cycle. try { await invoke('panel_opened') } catch {} try { @@ -60,7 +62,6 @@ onMounted(async () => { console.warn('Robin: failed to drain pending events:', err) } - // Always set up the live listener, even if drain failed unlisten = await listen('robin:event', ({ payload }) => { pushRobinEvent(payload) }) @@ -69,6 +70,7 @@ onMounted(async () => { onUnmounted(() => { unlisten?.() invoke('panel_closed').catch(() => {}) + activeCleanup?.() }) function pushRobinEvent(e: RobinEvent) { @@ -80,13 +82,55 @@ function pushRobinEvent(e: RobinEvent) { async function send() { const text = input.value.trim() - if (!text) return + if (!text || thinking.value) return input.value = '' + thinking.value = true + messages.value.push({ role: 'user', content: text }) - // M4: invoke('chat', { message: text }) and stream response - messages.value.push({ role: 'robin', content: '(LLM chat not yet connected — M4)' }) + messages.value.push({ role: 'robin', content: '' }) + const robinIdx = messages.value.length - 1 + await nextTick() messagesEl.value?.scrollTo({ top: messagesEl.value.scrollHeight, behavior: 'smooth' }) + + let unlistenToken: UnlistenFn | null = null + let unlistenDone: UnlistenFn | null = null + let unlistenError: UnlistenFn | null = null + + function cleanup() { + unlistenToken?.() + unlistenDone?.() + unlistenError?.() + thinking.value = false + activeCleanup = null + } + activeCleanup = cleanup + + unlistenToken = await listen('robin:chat-token', ({ payload }) => { + messages.value[robinIdx].content += payload + nextTick(() => { + messagesEl.value?.scrollTo({ top: messagesEl.value.scrollHeight, behavior: 'smooth' }) + }) + }) + + unlistenDone = await listen('robin:chat-done', () => cleanup()) + + unlistenError = await listen('robin:chat-error', ({ payload }) => { + if (!messages.value[robinIdx].content) { + messages.value[robinIdx].content = + `Robin couldn't reach Ollama. Make sure it's running at the configured URL. (${payload})` + } + cleanup() + }) + + try { + await invoke('chat', { message: text }) + } catch (err) { + if (!messages.value[robinIdx].content) { + messages.value[robinIdx].content = `Something went wrong starting the chat. (${err})` + } + cleanup() + } } @@ -154,4 +198,6 @@ async function send() { cursor: pointer; } .send-btn:hover { opacity: 0.85; } +.send-btn:disabled { opacity: 0.5; cursor: not-allowed; } +.chat-input:disabled { opacity: 0.6; cursor: not-allowed; }