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