Files
FabledScribe/docs/superpowers/plans/2026-04-03-chatpanel-unification.md
T
2026-04-03 15:42:01 -04:00

70 KiB
Raw Blame History

ChatPanel Unification Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Replace four divergent chat surfaces with a single ChatPanel component so every fix and feature applies everywhere automatically.

Architecture: Extract ChatInputBar.vue (input + note picker + PTT) and ChatStreamingBubble.vue (streaming state display), then build ChatPanel.vue with variant="full" and variant="widget", and migrate each view to use it. ChatPanel reads from useChatStore directly — no prop-drilling of messages or streaming state.

Tech Stack: Vue 3 Composition API, TypeScript, Pinia (useChatStore), existing composables (useStreamingTts, useVoiceRecorder, useVoiceAudio, useListenMode)

Important context:

  • ChatPanel.vue already exists but is orphaned (zero usages). Overwrite it completely.
  • InlineAssistPanel.vue and HistoryPanel.vue are also orphaned — ignore them.
  • chatStore.sendMessage(content, contextNoteId?, includeNoteIds?, think?, contextNoteTitle?, excludeNoteIds?, ragProjectId?, workspaceProjectId?) — always pass think: true
  • The store adds an optimistic user message immediately in sendMessage, so no manual optimistic push needed.
  • TypeScript check command: cd frontend && npm run typecheck (or make typecheck from repo root)

File Map

File Action Purpose
frontend/src/components/ChatInputBar.vue Create Unified input bar: textarea, note picker, PTT, send/abort
frontend/src/components/ChatStreamingBubble.vue Create Streaming state: tool calls, think block, content, typing indicator
frontend/src/components/ChatPanel.vue Overwrite Full+widget chat panel, owns TTS/listen/scroll
frontend/src/views/ChatView.vue Modify Replace chat-main section with <ChatPanel variant="full">
frontend/src/views/BriefingView.vue Modify Replace briefing-center chat with <ChatPanel variant="full" briefingMode>
frontend/src/views/WorkspaceView.vue Modify Replace inline chat with <ChatPanel variant="full" :projectId>
frontend/src/views/HomeView.vue Modify Replace DashboardChatInput + response section with <ChatPanel variant="widget">
frontend/src/components/DashboardChatInput.vue Delete Replaced by ChatPanel widget

Task 1: Create ChatInputBar.vue

Files:

  • Create: frontend/src/components/ChatInputBar.vue

  • Step 1: Create the file

<script setup lang="ts">
import { ref, computed, nextTick } from 'vue'
import { apiGet, transcribeAudio } from '@/api/client'
import { useVoiceRecorder } from '@/composables/useVoiceRecorder'
import { useChatStore } from '@/stores/chat'
import { useSettingsStore } from '@/stores/settings'
import type { Note } from '@/types/note'

const props = withDefaults(defineProps<{
  /** Textarea placeholder */
  placeholder?: string
  /** When true, hides the note picker (briefing mode) */
  briefingMode?: boolean
  /** Pill shape — compact rounded style for widget */
  pill?: boolean
}>(), {
  placeholder: 'Type a message… (Enter to send, Shift+Enter for new line)',
  briefingMode: false,
  pill: false,
})

const emit = defineEmits<{
  submit: [payload: { content: string; contextNoteId?: number }]
  abort: []
}>()

const store = useChatStore()
const settingsStore = useSettingsStore()
const voiceEnabled = computed(() => settingsStore.voiceSttReady)

// ── Core input ────────────────────────────────────────────────────────────────
const messageInput = ref('')
const inputEl = ref<HTMLTextAreaElement | null>(null)
const wrapperEl = ref<HTMLElement | null>(null)

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

function resetTextareaHeight() {
  const el = inputEl.value
  if (!el) return
  el.style.height = 'auto'
}

function onInputKeydown(e: KeyboardEvent) {
  if (e.key === 'Enter' && !e.shiftKey) {
    e.preventDefault()
    onSubmit()
  }
}

function onSubmit() {
  const content = messageInput.value.trim()
  if (!content) return
  emit('submit', { content, contextNoteId: attachedNote.value?.id })
  messageInput.value = ''
  attachedNote.value = null
  resetTextareaHeight()
}

// ── Note picker ───────────────────────────────────────────────────────────────
const attachedNote = ref<{ id: number; title: string } | null>(null)
const showNotePicker = ref(false)
const noteSearchQuery = ref('')
const noteSearchResults = ref<{ id: number; title: string }[]>([])
const noteSearchLoading = ref(false)
let noteSearchTimer: ReturnType<typeof setTimeout> | null = null

function toggleNotePicker() {
  showNotePicker.value = !showNotePicker.value
  if (showNotePicker.value) {
    noteSearchQuery.value = ''
    noteSearchResults.value = []
    nextTick(() => {
      (wrapperEl.value?.querySelector('.note-picker-search') as HTMLInputElement)?.focus()
    })
  }
}

function onNoteSearchInput() {
  if (noteSearchTimer) clearTimeout(noteSearchTimer)
  noteSearchTimer = setTimeout(async () => {
    const q = noteSearchQuery.value.trim()
    if (!q) { noteSearchResults.value = []; return }
    noteSearchLoading.value = true
    try {
      const data = await apiGet<{ notes: Note[] }>(`/api/notes?q=${encodeURIComponent(q)}&all=true&limit=5`)
      noteSearchResults.value = data.notes.map((n) => ({ id: n.id, title: n.title }))
    } catch {
      noteSearchResults.value = []
    } finally {
      noteSearchLoading.value = false
    }
  }, 250)
}

function selectNote(note: { id: number; title: string }) {
  attachedNote.value = note
  showNotePicker.value = false
}

function removeAttachedNote() {
  attachedNote.value = null
}

// ── PTT ───────────────────────────────────────────────────────────────────────
const transcribingVoice = ref(false)
const recorder = useVoiceRecorder()

async function startPtt() {
  if (!voiceEnabled.value || recorder.recording.value) return
  await recorder.startRecording()
}

async function stopPtt() {
  if (!recorder.recording.value) return
  transcribingVoice.value = true
  try {
    const blob = await recorder.stopRecording()
    const { transcript } = await transcribeAudio(blob)
    if (transcript.trim()) {
      messageInput.value = transcript.trim()
      await nextTick()
      autoResize()
      onSubmit()
    }
  } catch { /* transcription failed silently */ }
  finally { transcribingVoice.value = false }
}

// ── Exposed interface ─────────────────────────────────────────────────────────
function focus() {
  inputEl.value?.focus()
}

function prefill(text: string) {
  messageInput.value = text
  nextTick(() => {
    autoResize()
    inputEl.value?.focus()
  })
}

defineExpose({ focus, prefill })
</script>

<template>
  <div ref="wrapperEl" class="chat-input-bar" :class="{ 'chat-input-bar--pill': pill }">
    <!-- Attached note pill -->
    <div v-if="attachedNote" class="attached-note">
      <span class="attached-note-pill">
        {{ attachedNote.title }}
        <button class="attached-note-remove" aria-label="Remove" @click="removeAttachedNote">&times;</button>
      </span>
    </div>

    <div class="input-row">
      <!-- Note picker -->
      <div v-if="!briefingMode" class="note-picker-wrapper">
        <button
          class="btn-icon"
          @click="toggleNotePicker"
          :disabled="!store.chatReady"
          title="Attach a note"
        >
          <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
            <path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/>
          </svg>
        </button>
        <div v-if="showNotePicker" class="note-picker-dropdown">
          <input
            class="note-picker-search"
            v-model="noteSearchQuery"
            @input="onNoteSearchInput"
            placeholder="Search notes..."
          />
          <div class="note-picker-results">
            <div
              v-for="note in noteSearchResults"
              :key="note.id"
              class="note-picker-item"
              @click="selectNote(note)"
            >{{ note.title || 'Untitled' }}</div>
            <div v-if="noteSearchLoading" class="note-picker-empty">Searching...</div>
            <div v-else-if="noteSearchQuery && !noteSearchResults.length" class="note-picker-empty">No notes found</div>
          </div>
        </div>
      </div>

      <!-- Textarea -->
      <textarea
        ref="inputEl"
        v-model="messageInput"
        @keydown="onInputKeydown"
        @input="autoResize"
        :placeholder="!store.chatReady ? 'Chat unavailable' : store.streaming ? 'Type to queue… (Enter to queue)' : placeholder"
        :disabled="!store.chatReady"
        rows="1"
        class="input-textarea"
      ></textarea>

      <!-- PTT mic -->
      <button
        v-if="voiceEnabled && recorder.isSupported"
        class="btn-icon btn-mic"
        :class="{ 'mic-recording': recorder.recording.value, 'mic-transcribing': transcribingVoice }"
        @mousedown.prevent="startPtt"
        @mouseup.prevent="stopPtt"
        @touchstart.prevent="startPtt"
        @touchend.prevent="stopPtt"
        :disabled="transcribingVoice || !store.chatReady"
        :title="recorder.recording.value ? 'Release to send' : 'Hold to speak'"
        aria-label="Push to talk"
      >
        <svg v-if="!transcribingVoice" width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
          <path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5zm6 6c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
        </svg>
        <svg v-else width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
          <circle cx="12" cy="12" r="8" opacity="0.35"/><circle cx="12" cy="12" r="4"/>
        </svg>
      </button>

      <!-- Abort (streaming) or Send -->
      <button
        v-if="store.streaming"
        class="btn-abort-inline"
        @click="emit('abort')"
        title="Stop generation"
      >
        <svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor"><rect width="14" height="14" rx="2"/></svg>
      </button>
      <button
        v-else
        class="btn-send"
        @click="onSubmit"
        :disabled="!messageInput.trim() || !store.chatReady"
      >&uarr;</button>
    </div>
  </div>
</template>

<style scoped>
.chat-input-bar {
  width: 100%;
}

.attached-note {
  padding: 0.25rem 0.5rem;
}
.attached-note-pill {
  display: inline-flex;
  align-items: center;
  gap: 0.2rem;
  background: var(--color-primary);
  color: #fff;
  border-radius: 10px;
  padding: 0.15rem 0.4rem;
  font-size: 0.75rem;
}
.attached-note-remove {
  background: none;
  border: none;
  color: rgba(255, 255, 255, 0.7);
  cursor: pointer;
  font-size: 0.9rem;
  line-height: 1;
  padding: 0 0.1rem;
}
.attached-note-remove:hover { color: #fff; }

.input-row {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  padding: 0.5rem 0.5rem 0.5rem 0.75rem;
  background: var(--color-input-bar-bg);
  border-radius: 12px;
  box-shadow: 0 2px 8px var(--color-shadow);
}

.chat-input-bar--pill .input-row {
  border-radius: 24px;
  padding: 0.6rem 0.6rem 0.6rem 1rem;
}

.input-textarea {
  flex: 1;
  resize: none;
  padding: 0.35rem 0.5rem;
  border: none;
  background: transparent;
  color: var(--color-input-bar-text);
  outline: none;
  font-family: inherit;
  font-size: 0.9rem;
  max-height: 150px;
  overflow-y: auto;
}
.input-textarea::placeholder { color: var(--color-input-bar-placeholder); }
.input-textarea:disabled { opacity: 0.5; }

.btn-icon {
  background: none;
  border: none;
  cursor: pointer;
  color: var(--color-input-bar-text);
  opacity: 0.6;
  padding: 0.2rem;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: var(--radius-sm);
  flex-shrink: 0;
}
.btn-icon:hover { opacity: 1; }
.btn-icon:disabled { opacity: 0.3; cursor: default; }

.btn-mic.mic-recording { opacity: 1; color: #ef4444; }
.btn-mic.mic-transcribing { opacity: 0.5; }

.note-picker-wrapper { position: relative; }
.note-picker-dropdown {
  position: absolute;
  bottom: calc(100% + 8px);
  left: 0;
  width: 260px;
  background: var(--color-bg-card);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  box-shadow: 0 4px 16px var(--color-shadow);
  z-index: 20;
  overflow: hidden;
}
.note-picker-search {
  width: 100%;
  padding: 0.45rem 0.65rem;
  border: none;
  border-bottom: 1px solid var(--color-border);
  background: transparent;
  color: var(--color-text);
  font-size: 0.85rem;
  outline: none;
  font-family: inherit;
  box-sizing: border-box;
}
.note-picker-results { max-height: 180px; overflow-y: auto; }
.note-picker-item {
  padding: 0.4rem 0.65rem;
  cursor: pointer;
  font-size: 0.85rem;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.note-picker-item:hover { background: var(--color-bg-secondary); }
.note-picker-empty { padding: 0.4rem 0.65rem; color: var(--color-text-muted); font-size: 0.8rem; }

.btn-send {
  width: 30px;
  min-width: 30px;
  height: 30px;
  padding: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background: linear-gradient(135deg, #6366f1, #4f46e5);
  color: #fff;
  border: none;
  border-radius: 50%;
  cursor: pointer;
  font-size: 1rem;
  flex-shrink: 0;
  transition: box-shadow 0.15s;
}
.btn-send:hover { box-shadow: 0 0 12px rgba(99, 102, 241, 0.5); }
.btn-send:disabled { opacity: 0.35; cursor: default; box-shadow: none; }

.btn-abort-inline {
  width: 28px;
  min-width: 28px;
  height: 28px;
  padding: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--color-bg-secondary);
  border: 1px solid var(--color-border);
  border-radius: 50%;
  cursor: pointer;
  flex-shrink: 0;
}
.btn-abort-inline:hover { border-color: #ef4444; color: #ef4444; }
</style>
  • Step 2: TypeScript check
cd /path/to/project/frontend && npm run typecheck 2>&1 | tail -20

Expected: no errors related to ChatInputBar.vue.

  • Step 3: Commit
git add frontend/src/components/ChatInputBar.vue
git commit -m "feat: add ChatInputBar unified input bar component"

Task 2: Create ChatStreamingBubble.vue

Files:

  • Create: frontend/src/components/ChatStreamingBubble.vue

  • Step 1: Create the file

This extracts the identical streaming bubble HTML that currently lives in both ChatView.vue and WorkspaceView.vue.

<script setup lang="ts">
import { computed } from 'vue'
import { useChatStore } from '@/stores/chat'
import { useSettingsStore } from '@/stores/settings'
import { renderMarkdown } from '@/utils/markdown'
import ToolCallCard from '@/components/ToolCallCard.vue'
import ToolConfirmCard from '@/components/ToolConfirmCard.vue'

const store = useChatStore()
const settingsStore = useSettingsStore()

const streamingRendered = computed(() => {
  if (!store.streamingContent) return ''
  return renderMarkdown(store.streamingContent)
})
</script>

<template>
  <div class="chat-message role-assistant">
    <div class="message-bubble streaming-bubble">
      <div class="message-header">
        <span class="role-label">{{ settingsStore.assistantName }}</span>
      </div>
      <div v-if="store.streamingToolCalls.length" class="streaming-tool-calls">
        <ToolCallCard
          v-for="(tc, i) in store.streamingToolCalls"
          :key="i"
          :tool-call="tc"
        />
      </div>
      <ToolConfirmCard
        v-if="store.streamingPendingTool"
        :pending-tool="store.streamingPendingTool"
        @accept="store.confirmTool(true)"
        @decline="store.confirmTool(false)"
      />
      <div v-if="store.streamingStatus" class="streaming-status-line">
        <span class="streaming-status-dot"></span>
        {{ store.streamingStatus }}
      </div>
      <details
        v-if="store.streamingThinking"
        class="thinking-block"
        :open="!store.streamingContent"
      >
        <summary class="thinking-summary">Reasoning</summary>
        <pre class="thinking-text">{{ store.streamingThinking }}</pre>
      </details>
      <div class="message-content prose" v-html="streamingRendered"></div>
      <span
        v-if="!store.streamingStatus && !store.streamingThinking && !store.streamingContent"
        class="typing-indicator"
      ></span>
    </div>
  </div>
</template>

<style scoped>
/* All styles intentionally omitted here — the parent ChatPanel carries the
   shared chat-message / streaming-bubble CSS. ChatStreamingBubble is always
   rendered inside a ChatPanel and inherits those styles via :deep() or global
   scoped styles on the .messages-container ancestor. */
</style>
  • Step 2: TypeScript check
cd /path/to/project/frontend && npm run typecheck 2>&1 | tail -20

Expected: no errors.

  • Step 3: Commit
git add frontend/src/components/ChatStreamingBubble.vue
git commit -m "feat: add ChatStreamingBubble extracted streaming state component"

Task 3: Rewrite ChatPanel.vue — full variant

Files:

  • Overwrite: frontend/src/components/ChatPanel.vue

The existing ChatPanel.vue is orphaned (zero usages) — overwrite it entirely.

Context for this component:

  • chatStore.sendMessage(content, contextNoteId?, includeNoteIds?, think?, contextNoteTitle?, excludeNoteIds?, ragProjectId?, workspaceProjectId?)

  • Always pass think: true

  • For workspace: both ragProjectId and workspaceProjectId = props.projectId

  • For briefing: no extra args beyond content

  • For full chat: pass includedNoteIds, excludedNoteIds, store.ragProjectId

  • Step 1: Write the full variant implementation

<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted } from 'vue'
import { useChatStore } from '@/stores/chat'
import { useSettingsStore } from '@/stores/settings'
import { useStreamingTts } from '@/composables/useStreamingTts'
import { useVoiceAudio, setVoiceVolume } from '@/composables/useVoiceAudio'
import { useListenMode } from '@/composables/useListenMode'
import { apiGet } from '@/api/client'
import ChatMessage from '@/components/ChatMessage.vue'
import ChatStreamingBubble from '@/components/ChatStreamingBubble.vue'
import ChatInputBar from '@/components/ChatInputBar.vue'
import ToolCallCard from '@/components/ToolCallCard.vue'
import type { Message, ToolCallRecord } from '@/types/chat'

const props = withDefaults(defineProps<{
  variant: 'full' | 'widget'
  /** Workspace: pins RAG scope and workspace_project_id */
  projectId?: number
  /** Briefing: hides scope chip, hides note picker */
  briefingMode?: boolean
  /** Hides input bar — for read-only historical views */
  readOnly?: boolean
  placeholder?: string
  autoFocus?: boolean
}>(), {
  briefingMode: false,
  readOnly: false,
  autoFocus: false,
})

const emit = defineEmits<{
  /** Widget only: emitted when a new conversation is created */
  'conversation-started': [convId: number]
}>()

defineExpose({ focus, prefill })

const store = useChatStore()
const settingsStore = useSettingsStore()

// ── Voice / TTS ───────────────────────────────────────────────────────────────
const voiceTtsEnabled = computed(() => settingsStore.voiceTtsReady)
const listenMode = useListenMode()
const audio = useVoiceAudio()
const showVolumeSlider = ref(false)

const tts = useStreamingTts({
  streamingContent: computed(() => store.streamingContent),
  streaming: computed(() => store.streaming),
  enabled: computed(() => listenMode.value && voiceTtsEnabled.value),
})

function lastAssistantContent(): string {
  const msgs = store.currentConversation?.messages ?? []
  return [...msgs].reverse().find((m) => m.role === 'assistant')?.content ?? ''
}

function toggleListen() {
  listenMode.value = !listenMode.value
  if (listenMode.value) {
    tts.speak(lastAssistantContent())
  } else {
    tts.stop()
  }
}

// ── Scroll ────────────────────────────────────────────────────────────────────
const messagesEl = ref<HTMLElement | null>(null)

function scrollToBottom() {
  nextTick(() => {
    if (messagesEl.value) {
      messagesEl.value.scrollTop = messagesEl.value.scrollHeight
    }
  })
}

watch(() => store.streamingContent, () => scrollToBottom())
watch(() => store.currentConversation?.messages.length, () => scrollToBottom())

// ── RAG scope chip (full, non-briefing, non-workspace) ────────────────────────
const projects = ref<{ id: number; title: string }[]>([])
const scopeDropdownOpen = ref(false)
const scopePulse = ref(false)

const showScopeChip = computed(
  () => props.variant === 'full' && !props.briefingMode && !props.projectId
)

const scopeLabel = computed(() => {
  const id = store.ragProjectId
  if (id === -1) return 'All notes'
  if (id === null) return 'Orphan notes'
  return projects.value.find((p) => p.id === id)?.title ?? `Project ${id}`
})

async function loadProjects() {
  try {
    const data = await apiGet<{ projects: { id: number; title: string }[] }>('/api/projects?status=active')
    projects.value = data.projects ?? []
  } catch {
    projects.value = []
  }
}

async function onScopeSelect(value: number | null) {
  scopeDropdownOpen.value = false
  const convId = store.currentConversation?.id
  if (!convId) return
  await store.updateRagScope(convId, value)
  scopePulse.value = true
  setTimeout(() => { scopePulse.value = false }, 600)
}

// Watch for model-driven scope changes (set_rag_scope tool)
watch(() => store.ragProjectId, () => {
  if (!showScopeChip.value) return
  scopePulse.value = true
  setTimeout(() => { scopePulse.value = false }, 600)
})

// ── Note context (full variant — included / suggested / auto-injected notes) ──
const includedNoteIds = ref<Set<number>>(new Set())
const includedNotes = ref<{ id: number; title: string }[]>([])
const suggestedNotes = ref<{ id: number; title: string; score?: number | null }[]>([])
const autoInjectedNotes = ref<{ id: number; title: string; score?: number | null }[]>([])
const excludedNoteIds = ref<number[]>([])

watch(
  () => store.lastContextMeta,
  (meta) => {
    if (!meta || props.variant !== 'full') return
    const alreadyIncluded = includedNoteIds.value
    const alreadyAutoInjected = new Set(autoInjectedNotes.value.map((n) => n.id))
    const alreadySuggested = new Set(suggestedNotes.value.map((n) => n.id))

    for (const note of meta.auto_injected_notes ?? []) {
      if (!alreadyAutoInjected.has(note.id) && !alreadyIncluded.has(note.id)) {
        autoInjectedNotes.value.push(note)
        alreadyAutoInjected.add(note.id)
      }
    }
    for (const note of meta.auto_notes ?? []) {
      if (note.auto_injected) {
        if (!alreadyAutoInjected.has(note.id) && !alreadyIncluded.has(note.id)) {
          autoInjectedNotes.value.push(note)
          alreadyAutoInjected.add(note.id)
        }
      } else {
        if (!alreadyIncluded.has(note.id) && !alreadySuggested.has(note.id) && !alreadyAutoInjected.has(note.id)) {
          suggestedNotes.value.push(note)
        }
      }
    }
  }
)

function includeNote(note: { id: number; title: string }) {
  if (includedNoteIds.value.has(note.id)) return
  includedNoteIds.value = new Set([...includedNoteIds.value, note.id])
  includedNotes.value.push(note)
  suggestedNotes.value = suggestedNotes.value.filter((n) => n.id !== note.id)
  autoInjectedNotes.value = autoInjectedNotes.value.filter((n) => n.id !== note.id)
}

function excludeAutoNote(noteId: number) {
  autoInjectedNotes.value = autoInjectedNotes.value.filter((n) => n.id !== noteId)
  if (!excludedNoteIds.value.includes(noteId)) {
    excludedNoteIds.value = [...excludedNoteIds.value, noteId]
  }
}

function removeIncludedNote(noteId: number) {
  includedNoteIds.value = new Set([...includedNoteIds.value].filter((id) => id !== noteId))
  const removed = includedNotes.value.find((n) => n.id === noteId)
  includedNotes.value = includedNotes.value.filter((n) => n.id !== noteId)
  if (removed && !suggestedNotes.value.some((n) => n.id === noteId)) {
    suggestedNotes.value.push(removed)
  }
}

const hasContextSidebar = computed(
  () => props.variant === 'full' && !props.briefingMode && !props.projectId &&
    (autoInjectedNotes.value.length > 0 || includedNotes.value.length > 0 || suggestedNotes.value.length > 0)
)

// ── Send (full variant) ───────────────────────────────────────────────────────
const inputBarRef = ref<InstanceType<typeof ChatInputBar> | null>(null)

async function onSubmit(payload: { content: string; contextNoteId?: number }) {
  if (props.variant === 'widget') {
    await widgetSend(payload)
    return
  }
  if (!store.currentConversation) return
  await store.sendMessage(
    payload.content,
    payload.contextNoteId,
    includedNoteIds.value.size ? [...includedNoteIds.value] : undefined,
    true,
    undefined,
    excludedNoteIds.value.length ? excludedNoteIds.value : undefined,
    props.projectId ?? store.ragProjectId,
    props.projectId ?? undefined,
  )
  scrollToBottom()
  nextTick(() => inputBarRef.value?.focus())
}

// ── Widget state ──────────────────────────────────────────────────────────────
const widgetConvId = ref<number | null>(null)
const widgetQuery = ref('')
const widgetDone = ref(false)
const widgetFinalContent = ref('')
const widgetFinalToolCalls = ref<ToolCallRecord[]>([])

const isConversational = computed(
  () => widgetDone.value && widgetFinalToolCalls.value.length === 0
)

function clearWidget() {
  widgetConvId.value = null
  widgetDone.value = false
  widgetQuery.value = ''
  widgetFinalContent.value = ''
  widgetFinalToolCalls.value = []
}

async function widgetSend(payload: { content: string; contextNoteId?: number }) {
  widgetConvId.value = null
  widgetDone.value = false
  widgetFinalContent.value = ''
  widgetFinalToolCalls.value = []
  widgetQuery.value = payload.content

  const conv = await store.createConversation()
  await store.fetchConversation(conv.id)
  widgetConvId.value = conv.id
  emit('conversation-started', conv.id)

  await store.sendMessage(payload.content, payload.contextNoteId, undefined, true)

  const msgs = store.currentConversation?.messages ?? []
  const lastAssistant = [...msgs].reverse().find((m: Message) => m.role === 'assistant')
  widgetFinalContent.value = lastAssistant?.content ?? ''
  widgetFinalToolCalls.value = (lastAssistant?.tool_calls ?? []) as ToolCallRecord[]
  widgetDone.value = true
}

// ── Save as note ──────────────────────────────────────────────────────────────
async function handleSaveAsNote(messageId: number) {
  try {
    await store.saveMessageAsNote(messageId)
    const { useToastStore } = await import('@/stores/toast')
    useToastStore().show('Saved as note')
  } catch {
    const { useToastStore } = await import('@/stores/toast')
    useToastStore().show('Failed to save as note', 'error')
  }
}

// ── Lifecycle ─────────────────────────────────────────────────────────────────
onMounted(async () => {
  if (showScopeChip.value) await loadProjects()
  if (props.autoFocus) nextTick(() => inputBarRef.value?.focus())
})

// ── Exposed interface ─────────────────────────────────────────────────────────
function focus() {
  inputBarRef.value?.focus()
}

function prefill(text: string) {
  inputBarRef.value?.prefill(text)
}
</script>

<template>
  <!-- ═══════════════════════════════ FULL VARIANT ══════════════════════════════ -->
  <template v-if="variant === 'full'">
    <div class="chat-body" :class="{ 'chat-body--has-sidebar': hasContextSidebar }">
      <!-- Message list -->
      <div ref="messagesEl" class="messages-container">
        <div class="messages-inner">
          <ChatMessage
            v-for="msg in store.currentConversation?.messages ?? []"
            :key="msg.id"
            :message="msg"
            @save-as-note="handleSaveAsNote"
          />
          <!-- Streaming bubble -->
          <ChatStreamingBubble v-if="store.streaming" />
          <!-- Queued messages -->
          <template v-if="store.queuedMessages.length">
            <div
              v-for="(q, i) in store.queuedMessages"
              :key="`queued-${i}`"
              class="chat-message role-user queued-message"
            >
              <div class="message-bubble queued-bubble">
                <div class="queued-badge">Queued</div>
                <div class="message-content">{{ q.content }}</div>
              </div>
            </div>
            <div class="queued-clear-row">
              <button class="queued-clear-btn" @click="store.clearQueue()">
                Cancel {{ store.queuedMessages.length }} queued
              </button>
            </div>
          </template>
          <p
            v-if="!store.currentConversation?.messages.length && !store.streaming"
            class="empty-msg"
          >Send a message to start the conversation.</p>
        </div>
      </div>

      <!-- Context sidebar (full, non-briefing, non-workspace) -->
      <aside v-if="hasContextSidebar" class="context-sidebar">
        <template v-if="autoInjectedNotes.length">
          <div class="context-sidebar-header">Auto-included</div>
          <div v-for="note in autoInjectedNotes" :key="note.id" class="context-note context-note-auto">
            <router-link :to="`/notes/${note.id}`" class="context-note-name">{{ note.title }}</router-link>
            <span
              v-if="note.score != null"
              class="context-note-score"
              :class="{ 'score-high': note.score >= 0.75, 'score-medium': note.score >= 0.60 && note.score < 0.75, 'score-low': note.score < 0.60 }"
              :title="`Relevance: ${note.score}`"
            >{{ Math.round(note.score * 100) }}%</span>
            <button class="context-note-remove" @click="excludeAutoNote(note.id)">&times;</button>
          </div>
        </template>
        <template v-if="suggestedNotes.length">
          <div class="context-sidebar-header" :class="{ 'context-sidebar-header-gap': autoInjectedNotes.length }">Suggested</div>
          <div v-for="note in suggestedNotes" :key="note.id" class="context-note context-note-suggested">
            <router-link :to="`/notes/${note.id}`" class="context-note-name">{{ note.title }}</router-link>
            <span
              v-if="note.score != null"
              class="context-note-score"
              :class="{ 'score-high': note.score >= 0.75, 'score-medium': note.score >= 0.60 && note.score < 0.75, 'score-low': note.score < 0.60 }"
            >{{ Math.round(note.score * 100) }}%</span>
            <button class="context-note-add" @click="includeNote(note)" title="Add to context">+</button>
          </div>
        </template>
        <template v-if="includedNotes.length">
          <div class="context-sidebar-header" :class="{ 'context-sidebar-header-gap': autoInjectedNotes.length || suggestedNotes.length }">In Context</div>
          <div v-for="note in includedNotes" :key="note.id" class="context-note context-note-included">
            <router-link :to="`/notes/${note.id}`" class="context-note-name">{{ note.title }}</router-link>
            <button class="context-note-remove" @click="removeIncludedNote(note.id)">&times;</button>
          </div>
        </template>
      </aside>
    </div>

    <!-- Input area (hidden when readOnly) -->
    <div v-if="!readOnly" class="input-wrapper">
      <!-- Scope chip -->
      <div v-if="showScopeChip" class="scope-chip-row">
        <div class="scope-chip-wrapper">
          <button
            class="scope-chip"
            :class="{ pulse: scopePulse }"
            @click="scopeDropdownOpen = !scopeDropdownOpen"
            title="Change RAG scope"
          >
            <span class="scope-dot"></span> {{ scopeLabel }}
          </button>
          <div v-if="scopeDropdownOpen" class="scope-dropdown">
            <button class="scope-option" :class="{ active: store.ragProjectId === null }" @click="onScopeSelect(null)">Orphan notes only</button>
            <button
              v-for="p in projects"
              :key="p.id"
              class="scope-option"
              :class="{ active: store.ragProjectId === p.id }"
              @click="onScopeSelect(p.id)"
            >{{ p.title }}</button>
            <button class="scope-option" :class="{ active: store.ragProjectId === -1 }" @click="onScopeSelect(-1)">All notes</button>
          </div>
        </div>
      </div>

      <!-- Listen / volume controls -->
      <div v-if="voiceTtsEnabled" class="voice-controls">
        <div class="volume-wrapper">
          <button
            class="btn-voice"
            :class="{ 'btn-voice--active': listenMode, 'btn-voice--busy': tts.speaking.value }"
            @click="toggleListen"
            :title="listenMode ? 'Stop auto-read' : 'Read responses aloud'"
            aria-label="Toggle listen mode"
          >
            <svg v-if="!tts.speaking.value" width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
              <path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
            </svg>
            <svg v-else width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
              <path d="M18 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C21.8 14.82 22 13.43 22 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>
            </svg>
          </button>
          <button
            class="btn-voice btn-volume-icon"
            @click="showVolumeSlider = !showVolumeSlider"
            :title="`Volume: ${Math.round(audio.volume.value * 100)}%`"
          >
            <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
              <path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
            </svg>
          </button>
          <div v-if="showVolumeSlider" class="volume-popup">
            <input
              type="range" min="0" max="1" step="0.05"
              :value="audio.volume.value"
              @input="setVoiceVolume(+($event.target as HTMLInputElement).value)"
              class="volume-range"
            />
            <span class="volume-pct">{{ Math.round(audio.volume.value * 100) }}%</span>
          </div>
        </div>
        <button
          v-if="tts.speaking.value"
          class="btn-voice"
          @click="tts.stop()"
          title="Stop playback"
        >
          <svg width="13" height="13" viewBox="0 0 14 14" fill="currentColor"><rect width="14" height="14" rx="2"/></svg>
        </button>
      </div>

      <!-- Unified input bar -->
      <ChatInputBar
        ref="inputBarRef"
        :placeholder="placeholder"
        :briefing-mode="briefingMode"
        @submit="onSubmit"
        @abort="store.cancelGeneration()"
      />
    </div>
  </template>

  <!-- ══════════════════════════════ WIDGET VARIANT ══════════════════════════════ -->
  <template v-else>
    <ChatInputBar
      ref="inputBarRef"
      pill
      :placeholder="placeholder ?? 'Start a new chat… (Enter to send)'"
      :briefing-mode="false"
      @submit="onSubmit"
      @abort="store.cancelGeneration()"
    />

    <!-- Inline response area -->
    <div v-if="widgetConvId" class="widget-response">
      <div class="widget-query">{{ widgetQuery }}</div>

      <!-- Tool calls -->
      <div v-if="store.streaming && store.streamingToolCalls.length" class="widget-tool-calls">
        <ToolCallCard v-for="(tc, i) in store.streamingToolCalls" :key="i" :tool-call="tc" />
      </div>
      <div v-else-if="widgetDone && widgetFinalToolCalls.length" class="widget-tool-calls">
        <ToolCallCard v-for="(tc, i) in widgetFinalToolCalls" :key="i" :tool-call="tc" />
      </div>

      <!-- Streaming / final text -->
      <div v-if="store.streaming" class="widget-text streaming">
        <div v-if="store.streamingStatus && !store.streamingContent" class="widget-status">
          <span class="widget-status-dot"></span>{{ store.streamingStatus }}
        </div>
        <span v-else-if="store.streamingContent">{{ store.streamingContent }}</span>
        <span v-else class="thinking-dots">...</span>
      </div>
      <div v-else-if="widgetDone && widgetFinalContent" class="widget-text">
        {{ widgetFinalContent }}
      </div>

      <!-- Actions -->
      <div class="widget-actions" :class="{ conversational: isConversational }">
        <router-link
          :to="`/chat/${widgetConvId}`"
          class="btn-open-chat"
          :class="{ prominent: isConversational }"
        >{{ isConversational ? 'Continue the conversation →' : 'Think it through in Chat →' }}</router-link>
        <button class="btn-clear-response" @click="clearWidget">Clear</button>
      </div>
    </div>
  </template>
</template>

<style scoped>
/* ── Full variant layout ── */
.chat-body {
  flex: 1;
  display: flex;
  min-height: 0;
  overflow: hidden;
}
.chat-body--has-sidebar {
  gap: 0;
}

.messages-container {
  flex: 1;
  overflow-y: auto;
  padding: 1rem;
}
.messages-inner {
  display: flex;
  flex-direction: column;
  min-height: 100%;
  justify-content: flex-end;
}

/* Context sidebar — mirrors ChatView's existing styles */
.context-sidebar {
  width: 200px;
  min-width: 160px;
  max-width: 220px;
  border-left: 1px solid var(--color-border);
  padding: 0.75rem 0.5rem;
  overflow-y: auto;
  background: var(--color-bg-secondary);
  font-size: 0.78rem;
}
.context-sidebar-header {
  font-size: 0.65rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--color-text-muted);
  margin-bottom: 0.35rem;
}
.context-sidebar-header-gap { margin-top: 0.75rem; }
.context-note {
  display: flex;
  align-items: center;
  gap: 0.2rem;
  margin-bottom: 0.25rem;
}
.context-note-name {
  flex: 1;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  color: var(--color-text);
  text-decoration: none;
  font-size: 0.78rem;
}
.context-note-name:hover { color: var(--color-primary); }
.context-note-score {
  font-size: 0.65rem;
  padding: 0.05rem 0.25rem;
  border-radius: 4px;
  background: var(--color-bg);
}
.score-high { color: #22c55e; }
.score-medium { color: #f59e0b; }
.score-low { color: var(--color-text-muted); }
.context-note-remove, .context-note-add {
  background: none;
  border: none;
  cursor: pointer;
  color: var(--color-text-muted);
  font-size: 0.9rem;
  line-height: 1;
  padding: 0 0.1rem;
  flex-shrink: 0;
}
.context-note-remove:hover { color: #ef4444; }
.context-note-add:hover { color: var(--color-primary); }

/* Input wrapper */
.input-wrapper {
  border-top: 1px solid var(--color-border);
  padding: 0.5rem 1rem 0.75rem;
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
}

/* Scope chip */
.scope-chip-row { display: flex; }
.scope-chip-wrapper { position: relative; }
.scope-chip {
  display: flex;
  align-items: center;
  gap: 0.3rem;
  background: none;
  border: 1px solid var(--color-border);
  border-radius: 20px;
  padding: 0.2rem 0.65rem;
  font-size: 0.75rem;
  color: var(--color-text-muted);
  cursor: pointer;
  transition: border-color 0.15s, color 0.15s;
}
.scope-chip:hover { border-color: var(--color-primary); color: var(--color-primary); }
.scope-chip.pulse { animation: pulse-chip 0.6s ease; }
@keyframes pulse-chip {
  0%, 100% { border-color: var(--color-border); }
  50% { border-color: var(--color-primary); color: var(--color-primary); box-shadow: 0 0 6px rgba(99,102,241,0.4); }
}
.scope-dot { font-size: 0.6rem; }
.scope-dropdown {
  position: absolute;
  top: calc(100% + 4px);
  left: 0;
  min-width: 180px;
  background: var(--color-bg-card);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  box-shadow: 0 4px 16px var(--color-shadow);
  z-index: 20;
  overflow: hidden;
}
.scope-option {
  display: block;
  width: 100%;
  padding: 0.45rem 0.75rem;
  text-align: left;
  background: none;
  border: none;
  font-size: 0.85rem;
  color: var(--color-text);
  cursor: pointer;
}
.scope-option:hover { background: var(--color-bg-secondary); }
.scope-option.active { color: var(--color-primary); font-weight: 600; }

/* Voice controls */
.voice-controls {
  display: flex;
  align-items: center;
  gap: 0.2rem;
}
.volume-wrapper { position: relative; display: flex; align-items: center; gap: 0.1rem; }
.btn-voice {
  background: none;
  border: none;
  cursor: pointer;
  color: var(--color-text-muted);
  opacity: 0.65;
  padding: 0.2rem;
  display: flex;
  align-items: center;
  border-radius: var(--radius-sm);
  transition: opacity 0.15s, color 0.15s;
}
.btn-voice:hover { opacity: 1; }
.btn-voice--active { opacity: 1; color: var(--color-primary); }
.btn-voice--busy { opacity: 1; color: #f59e0b; }
.volume-popup {
  position: absolute;
  bottom: calc(100% + 8px);
  left: 0;
  background: var(--color-bg-card);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  padding: 0.5rem 0.75rem;
  display: flex;
  align-items: center;
  gap: 0.5rem;
  z-index: 20;
  box-shadow: 0 4px 16px var(--color-shadow);
}
.volume-range { width: 80px; }
.volume-pct { font-size: 0.75rem; color: var(--color-text-muted); white-space: nowrap; }

/* Queued messages */
.queued-message { opacity: 0.45; }
.queued-bubble {
  background: var(--color-bg-secondary);
  border: 1px dashed var(--color-border);
  border-radius: var(--radius-md);
  padding: 0.5rem 0.75rem;
  font-size: 0.85rem;
}
.queued-badge {
  font-size: 0.65rem;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--color-text-muted);
  font-weight: 700;
  margin-bottom: 0.2rem;
}
.queued-clear-row {
  display: flex;
  justify-content: center;
  padding: 0.25rem 0;
}
.queued-clear-btn {
  background: none;
  border: none;
  font-size: 0.75rem;
  color: var(--color-text-muted);
  cursor: pointer;
  text-decoration: underline;
}

.empty-msg {
  color: var(--color-text-muted);
  font-size: 0.9rem;
  text-align: center;
  padding: 2rem 1rem;
}

/* ── Widget variant ── */
.widget-response {
  margin-top: 0.75rem;
  background: var(--color-bg-card);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-lg);
  padding: 0.75rem 1rem;
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}
.widget-query {
  font-size: 0.8rem;
  color: var(--color-text-muted);
  font-style: italic;
}
.widget-text {
  font-size: 0.9rem;
  line-height: 1.55;
  color: var(--color-text);
}
.widget-text.streaming { color: var(--color-text-muted); }
.thinking-dots { color: var(--color-text-muted); letter-spacing: 0.2em; }
.widget-status {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  font-size: 0.8rem;
  color: var(--color-text-muted);
}
.widget-status-dot {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--color-primary);
  animation: blink 1s infinite;
}
@keyframes blink { 0%, 100% { opacity: 0.3; } 50% { opacity: 1; } }
.widget-tool-calls { display: flex; flex-direction: column; gap: 0.25rem; }
.widget-actions {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding-top: 0.25rem;
  border-top: 1px solid var(--color-border);
  flex-wrap: wrap;
}
.btn-open-chat {
  font-size: 0.8rem;
  color: var(--color-text-muted);
  text-decoration: none;
}
.btn-open-chat:hover { color: var(--color-primary); }
.btn-open-chat.prominent {
  font-size: 0.9rem;
  color: var(--color-primary);
  font-weight: 600;
}
.btn-clear-response {
  background: none;
  border: none;
  font-size: 0.75rem;
  color: var(--color-text-muted);
  cursor: pointer;
  margin-left: auto;
}
.btn-clear-response:hover { color: var(--color-text); }

/* Streaming bubble styles — same as ChatView */
:deep(.streaming-bubble) {
  max-width: 85%;
  padding: 0.75rem 1rem;
  border-radius: 16px 16px 16px 4px;
  background: var(--color-bg-card);
  border-left: 2px solid var(--color-primary);
  box-shadow: 0 1px 4px var(--color-shadow);
}
:deep(.streaming-tool-calls) { margin-bottom: 0.5rem; }
:deep(.streaming-status-line) {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  font-size: 0.8rem;
  color: var(--color-text-muted);
  margin-bottom: 0.25rem;
}
:deep(.streaming-status-dot) {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--color-primary);
  animation: blink 1s infinite;
}
:deep(.thinking-block) {
  margin-bottom: 0.5rem;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-sm);
  overflow: hidden;
}
:deep(.thinking-summary) {
  padding: 0.25rem 0.5rem;
  font-size: 0.72rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--color-text-muted);
  cursor: pointer;
  list-style: none;
  background: var(--color-bg-secondary);
}
:deep(.thinking-text) {
  margin: 0;
  padding: 0.5rem;
  font-size: 0.8rem;
  color: var(--color-text-secondary);
  white-space: pre-wrap;
  max-height: 300px;
  overflow-y: auto;
  background: var(--color-bg-secondary);
}
:deep(.typing-indicator) {
  display: inline-block;
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--color-primary);
  animation: blink 1s infinite;
  margin-left: 4px;
  vertical-align: middle;
}
</style>
  • Step 2: TypeScript check
cd /path/to/project/frontend && npm run typecheck 2>&1 | tail -30

Expected: no errors in ChatPanel.vue, ChatInputBar.vue, or ChatStreamingBubble.vue.

  • Step 3: Commit
git add frontend/src/components/ChatPanel.vue
git commit -m "feat: rewrite ChatPanel with full and widget variants"

Task 4: Migrate ChatView.vue

Files:

  • Modify: frontend/src/views/ChatView.vue

ChatView keeps: sidebar (conversation list, bulk delete), route wiring, onMounted/watch(convId), summarize button, research modal. It removes: all input bar HTML, streaming bubble HTML, scroll management, TTS/PTT/listen wiring, note context state.

  • Step 1: Remove imports that ChatPanel now owns

In <script setup>, remove these lines:

// REMOVE these imports:
import { useStreamingTts } from "@/composables/useStreamingTts";
import { useVoiceRecorder } from "@/composables/useVoiceRecorder";
import { useVoiceAudio, setVoiceVolume } from "@/composables/useVoiceAudio";
import { useListenMode } from "@/composables/useListenMode";
import ToolCallCard from "@/components/ToolCallCard.vue";
import ToolConfirmCard from "@/components/ToolConfirmCard.vue";

Add this import:

import ChatPanel from "@/components/ChatPanel.vue";
  • Step 2: Remove refs/computed/functions that ChatPanel now owns

Remove ALL of the following from <script setup>:

// REMOVE — TTS / listen / voice
const transcribingVoice = ref(false);
const recorder = useVoiceRecorder();
const audio = useVoiceAudio();
const voiceEnabled = computed(...)
const voiceTtsEnabled = computed(...)
const listenMode = useListenMode();
const showVolumeSlider = ref(false);
const tts = useStreamingTts({...});
async function startPtt() {...}
async function stopPtt() {...}

// REMOVE — note context / sidebar
const includedNoteIds = ref(...)
const includedNotes = ref(...)
const suggestedNotes = ref(...)
const autoInjectedNotes = ref(...)
const excludedNoteIds = ref(...)
watch(() => store.lastContextMeta, ...)
function includeNote(...) {...}
function excludeAutoNote(...) {...}
function removeIncludedNote(...) {...}

// REMOVE — input / scroll
const messageInput = ref("");
const messagesEl = ref<HTMLElement | null>(null);
const inputEl = ref<HTMLTextAreaElement | null>(null);
const streamingRendered = computed(...)
watch(() => store.streamingContent, () => scrollToBottom())
function scrollToBottom() {...}
function autoResize() {...}
function resetTextareaHeight() {...}
function onInputKeydown(...) {...}

// REMOVE — scope chip
const scopeDropdownOpen = ref(false);
const projects = ref([]);
const scopePulse = ref(false);
const scopeLabel = computed(...)
async function loadProjects() {...}
async function onScopeSelect(...) {...}

// REMOVE — note picker
const showNotePicker = ref(false);
const noteSearchQuery = ref("");
const noteSearchResults = ref([]);
const noteSearchLoading = ref(false);
let noteSearchTimer ...
function toggleNotePicker() {...}
function onNoteSearchInput() {...}
function selectNote(...) {...}
function includeNote(...) {...}
function removeIncludedNote(...) {...}

Keep: sending, summarizing, sidebarOpen, convId, prevConvId, groupedConversations, inputPlaceholder, onMounted, watch(convId), startNewConversation, newConversation, bulk delete state/functions, selectConversation, removeConversation, sendMessage (used by research modal only now — see Step 3), handleSummarize, handleSaveAsNote, onGlobalKeydown, onUnmounted.

  • Step 3: Adapt sendMessage and onMounted

sendMessage in ChatView was the primary send path. Now it's only used by the research modal's submitResearch. Simplify it:

// Keep only for research modal usage
async function sendMessage(content?: string) {
  const text = (content ?? '').trim()
  if (!text) return
  if (!store.currentConversation) {
    const conv = await store.createConversation()
    await store.fetchConversation(conv.id)
    router.push(`/chat/${conv.id}`)
  }
  // ChatPanel handles the actual send — just put the text in its input
  chatPanelRef.value?.prefill(text)
}

// submitResearch becomes:
function submitResearch() {
  const topic = researchTopic.value.trim()
  if (!topic) return
  showResearchModal.value = false
  researchTopic.value = ""
  chatPanelRef.value?.prefill(`Research: ${topic}`)
}

Add ref for ChatPanel:

const chatPanelRef = ref<InstanceType<typeof ChatPanel> | null>(null)

Remove loadProjects() from onMounted:

// Before:
await Promise.all([store.fetchConversations(), loadProjects()])
// After:
await store.fetchConversations()
  • Step 4: Replace the <section class="chat-main"> template block

Replace the entire <section class="chat-main"> block (roughly lines 6241003) with:

<section class="chat-main">
  <template v-if="store.currentConversation">
    <div class="chat-header">
      <button class="btn-sidebar-toggle hide-desktop" aria-label="Toggle sidebar" @click="toggleSidebar">
        <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
          <line x1="3" y1="6" x2="21" y2="6"/>
          <line x1="3" y1="12" x2="21" y2="12"/>
          <line x1="3" y1="18" x2="21" y2="18"/>
        </svg>
      </button>
      <h2>{{ store.currentConversation.title || "New Chat" }}</h2>

      <!-- Research modal trigger -->
      <div class="research-wrapper">
        <button
          class="btn-research-trigger"
          @click="toggleResearchModal"
          :disabled="store.streaming || !store.chatReady"
          title="Research a topic"
        >
          <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
            <circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
          </svg>
        </button>
        <div v-if="showResearchModal" class="research-modal">
          <div class="research-modal-header">Research topic</div>
          <input
            class="research-topic-input"
            v-model="researchTopic"
            placeholder="e.g. quantum computing"
            @keydown.enter="submitResearch"
            @keydown.escape="showResearchModal = false"
          />
          <div class="research-modal-actions">
            <button class="btn-research-cancel" @click="showResearchModal = false">Cancel</button>
            <button class="btn-research-go" @click="submitResearch" :disabled="!researchTopic.trim()">Go</button>
          </div>
        </div>
      </div>

      <button
        v-if="store.currentConversation.messages.length"
        class="btn-summarize"
        @click="handleSummarize"
        :disabled="summarizing || store.streaming"
      >{{ summarizing ? "Summarizing..." : "Summarize as Note" }}</button>
    </div>

    <ChatPanel
      ref="chatPanelRef"
      variant="full"
      :auto-focus="true"
      class="chat-panel-area"
    />
  </template>

  <div v-else class="no-conversation">
    <button class="btn-sidebar-toggle no-conv-toggle hide-desktop" @click="toggleSidebar">
      <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
        <line x1="3" y1="6" x2="21" y2="6"/>
        <line x1="3" y1="12" x2="21" y2="12"/>
        <line x1="3" y1="18" x2="21" y2="18"/>
      </svg>
    </button>
    <p>Select a conversation or start a new chat.</p>
    <button class="btn-new-conv" @click="newConversation">+ New Chat</button>
  </div>
</section>

Also add to the <style scoped> section:

.chat-main {
  flex: 1;
  display: flex;
  flex-direction: column;
  min-width: 0;
  overflow: hidden;
}
.chat-panel-area {
  flex: 1;
  display: contents; /* ChatPanel renders full+widget as template fragments */
}
/* Re-add chat-body layout so ChatPanel's .chat-body fills the main area */
:deep(.chat-body) {
  flex: 1;
  min-height: 0;
}
  • Step 5: TypeScript check
cd /path/to/project/frontend && npm run typecheck 2>&1 | grep -v "node_modules" | head -30

Expected: no new errors. Fix any that appear.

  • Step 6: Commit
git add frontend/src/views/ChatView.vue
git commit -m "refactor: migrate ChatView to use ChatPanel component"

Task 5: Migrate BriefingView.vue

Files:

  • Modify: frontend/src/views/BriefingView.vue

BriefingView keeps: weather, news, conversation history dropdown, triggerNow, discussArticle, RSS reactions, background refresh. It removes: local messages ref, streaming watch, PTT/voice wiring, scroll management, input bar HTML, toMsg adapter.

  • Step 1: Replace imports

Remove from imports:

// REMOVE:
import { useVoiceRecorder } from '@/composables/useVoiceRecorder'
import { useVoiceAudio } from '@/composables/useVoiceAudio'
import { useListenMode } from '@/composables/useListenMode'
import { useStreamingTts } from '@/composables/useStreamingTts'
import ChatMessage from '@/components/ChatMessage.vue'
import { transcribeAudio } from '@/api/client'  // only if not used elsewhere
import type { Message } from '@/types/chat'       // only if not used elsewhere

Add:

import ChatPanel from '@/components/ChatPanel.vue'
  • Step 2: Remove voice/TTS/scroll state and functions

Remove:

// REMOVE:
const voiceEnabled = computed(...)
const voiceTtsEnabled = computed(...)
const listenMode = useListenMode()
const transcribing = ref(false)
const tts = useStreamingTts({...})
const recorder = useVoiceRecorder()
const audio = useVoiceAudio()
async function startPtt() {...}
async function stopPtt() {...}
const messagesEl = ref(...)
function scrollToBottom() {...}
function toMsg(m: BriefingMessage): Message {...}
const messages = ref<BriefingMessage[]>([])
const loadingMessages = ref(false)
  • Step 3: Update streaming watch and selectedConvId watcher

Remove the streaming watch entirely — ChatPanel handles it:

// REMOVE this entire watch:
watch(() => chatStore.streaming, async (streaming) => {
  if (!streaming && _mounted && selectedConvId.value === todayConvId.value) {
    ...
  }
})

Update the selectedConvId watcher to use chatStore for ALL conversations (not just today):

watch(selectedConvId, async (id) => {
  if (!id) return
  try {
    await chatStore.fetchConversation(id)
  } catch {
    // Historical conversation unavailable — do nothing
  }
})

Also update loadAll — remove local messages assignment, just fetch into store:

async function loadAll() {
  const [convList, today] = await Promise.all([
    getBriefingConversations(),
    getBriefingToday().catch(() => null),
    loadWeather(),
    loadNews(),
  ])
  conversations.value = convList
  if (today) {
    todayConvId.value = today.id
    if (!convList.find((c) => c.id === today.id)) {
      conversations.value = [
        { id: today.id, title: today.title ?? 'Today', briefing_date: null, message_count: 0, created_at: new Date().toISOString() },
        ...convList,
      ]
    }
    selectedConvId.value = today.id
    await chatStore.fetchConversation(today.id)
  }
}
  • Step 4: Simplify send and discussArticle
async function send(overrideText?: string) {
  const text = (overrideText ?? input.value).trim()
  if (!text || !todayConvId.value || chatStore.streaming || sending.value) return
  if (chatStore.currentConversation?.id !== todayConvId.value) {
    await chatStore.fetchConversation(todayConvId.value)
  }
  input.value = ''
  sending.value = true
  try {
    await chatStore.sendMessage(text, undefined, undefined, true)
  } finally {
    sending.value = false
  }
}

async function discussArticle(item: NewsItem) {
  if (!todayConvId.value) return
  if (!isToday.value) selectedConvId.value = todayConvId.value
  const body = (item.content || item.snippet || '').trim()
  const bodyBlock = body ? `\n\n---\n${body}\n---` : ''
  const text = `Please summarize and share your thoughts on this article. Do not use any research or search tools — respond conversationally based only on the content below.\n\n**${item.title}**\nSource: ${item.source}${bodyBlock}`
  // Scroll to chat column
  await nextTick(() => {
    document.querySelector('.briefing-center')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
  })
  await send(text)
}

Remove onKeydown (ChatPanel's ChatInputBar handles Enter), remove input ref and sending ref if only used for the textarea (they're still used — send still needs them for the briefing, since BriefingView has its own input bar... wait, no — after migration, BriefingView's input bar is inside ChatPanel. So input and the onKeydown handler move into ChatPanel/ChatInputBar.)

Actually: after migration, BriefingView's textarea is gone — it's inside ChatPanel. input ref and sending ref are no longer needed in BriefingView. The send function is simplified to just call chatStore.sendMessage. But BriefingView still needs send for discussArticle.

Revised:

// input ref and sending ref are no longer needed — REMOVE them
// Keep send() only as a helper for discussArticle:
async function send(text: string) {
  if (!text || !todayConvId.value || chatStore.streaming) return
  if (chatStore.currentConversation?.id !== todayConvId.value) {
    await chatStore.fetchConversation(todayConvId.value)
  }
  await chatStore.sendMessage(text, undefined, undefined, true)
}
  • Step 5: Replace the briefing-center template block

Replace the center column HTML:

<!-- Center column: Chat -->
<div class="briefing-center">
  <ChatPanel
    variant="full"
    briefingMode
    :readOnly="!isToday"
    placeholder="Reply to your briefing…"
    class="briefing-chat-panel"
  />
</div>

Remove the <div class="briefing-messages-wrap"> and <div class="briefing-input-bar"> blocks entirely.

  • Step 6: TypeScript check
cd /path/to/project/frontend && npm run typecheck 2>&1 | grep -v "node_modules" | head -30
  • Step 7: Commit
git add frontend/src/views/BriefingView.vue
git commit -m "refactor: migrate BriefingView chat to use ChatPanel component"

Task 6: Migrate WorkspaceView.vue

Files:

  • Modify: frontend/src/views/WorkspaceView.vue

WorkspaceView keeps: 3-panel grid, project loading, conversation lifecycle (create/restore/delete), WorkspaceTaskPanel, WorkspaceNoteEditor, quick chips (prefill via ChatPanel ref). It removes: inline chat HTML, scroll management, input/textarea state, TTS/listen wiring, sendMessage function.

  • Step 1: Replace imports

Remove:

// REMOVE:
import { useStreamingTts } from "@/composables/useStreamingTts";
import { useListenMode } from "@/composables/useListenMode";
import { renderMarkdown } from "@/utils/markdown";
import ChatMessage from "@/components/ChatMessage.vue";
import ToolCallCard from "@/components/ToolCallCard.vue";
import ToolConfirmCard from "@/components/ToolConfirmCard.vue";

Add:

import ChatPanel from "@/components/ChatPanel.vue";
  • Step 2: Remove state and functions owned by ChatPanel

Remove:

// REMOVE:
const listenMode = useListenMode();
const voiceTtsEnabled = computed(...);
const tts = useStreamingTts({...});
const messageInput = ref("");
const inputEl = ref<HTMLTextAreaElement | null>(null);
const streamingRendered = computed(...);
watch(() => chatStore.streamingContent, scrollToBottom);
watch(() => chatStore.currentConversation?.messages.length, scrollToBottom);
function scrollToBottom() {...}
function autoResize() {...}
function resetTextareaHeight() {...}
function onInputKeydown(...) {...}
async function sendMessage() {...}

Change prefill to delegate to ChatPanel:

const chatPanelRef = ref<InstanceType<typeof ChatPanel> | null>(null)

function prefill(text: string) {
  chatPanelRef.value?.prefill(text)
}

Keep in onMounted: the workspace conversation create/restore logic, project loading, chatStore.reconnectIfGenerating.

  • Step 3: Replace the inline chat panel HTML

In the template, find the center ws-panel (the one containing messages-container and chat-input-area). Replace its contents:

<!-- Center: Chat -->
<div v-show="panelOpen.chat" class="ws-panel ws-panel-chat">
  <Transition name="panel-fade">
    <div v-if="panelOpen.chat" class="panel-inner panel-inner-chat">
      <!-- Quick chips (shown when conversation is empty) -->
      <div
        v-if="chatStore.currentConversation && !chatStore.currentConversation.messages.length && !chatStore.streaming"
        class="empty-chat-prompt"
      >
        <p class="empty-hint">What would you like to work on?</p>
        <div class="quick-chips">
          <button class="quick-chip" @click="prefill('Summarize the current status of this project')">📊 Project status</button>
          <button class="quick-chip" @click="prefill('Create a note about ')">📝 New note</button>
          <button class="quick-chip" @click="prefill('Add tasks for ')"> Add tasks</button>
        </div>
      </div>
      <ChatPanel
        ref="chatPanelRef"
        variant="full"
        :projectId="projectId"
        placeholder="Message the agent… (Enter to send)"
        class="ws-chat-panel"
      />
    </div>
  </Transition>
</div>
  • Step 4: TypeScript check
cd /path/to/project/frontend && npm run typecheck 2>&1 | grep -v "node_modules" | head -30
  • Step 5: Commit
git add frontend/src/views/WorkspaceView.vue
git commit -m "refactor: migrate WorkspaceView chat to use ChatPanel component"

Task 7: Migrate HomeView.vue + delete DashboardChatInput.vue

Files:

  • Modify: frontend/src/views/HomeView.vue

  • Delete: frontend/src/components/DashboardChatInput.vue

  • Step 1: Update HomeView imports

Remove:

// REMOVE:
import DashboardChatInput from "@/components/DashboardChatInput.vue";
import ToolCallCard from "@/components/ToolCallCard.vue";
import type { ToolCallRecord, Message } from "@/types/chat";

Add:

import ChatPanel from "@/components/ChatPanel.vue";

Keep: Message type import only if used elsewhere in the file. Check if ToolCallRecord or Message are used anywhere else in HomeView — they were only used for dashboard response. Remove them.

  • Step 2: Remove dashboard chat state and functions

Remove from <script setup>:

// REMOVE:
const chatInputRef = ref(...)
const dashboardConvId = ref<number | null>(null)
const dashboardDone = ref(false)
const dashboardQuery = ref("")
const dashboardFinalContent = ref("")
const dashboardFinalToolCalls = ref<ToolCallRecord[]>([])
const isConversational = computed(...)
async function onChatSubmit(...) {...}
async function onQuickAction(query: string) {...}
function clearDashboardResponse() {...}

Keep: chatStore usage (for chatStore.streaming, chatStore.chatReady in quick action chips).

Replace quick action handler — quick actions now use ChatPanel:

const chatPanelRef = ref<InstanceType<typeof ChatPanel> | null>(null)

async function onQuickAction(query: string) {
  chatPanelRef.value?.prefill(query)
  // ChatPanel will handle the send when user hits Enter, or we can trigger programmatically:
  // Actually prefill + auto-send: use the ref to submit
}

Wait — prefill only fills the textarea, it doesn't submit. For quick actions in HomeView, the old behavior was to auto-submit. We need to either: a) Have the user press Enter after prefill (different UX) b) Expose a send(text) method on ChatPanel that calls onSubmit directly

Add send(text: string) to ChatPanel's defineExpose:

// In ChatPanel.vue, add to defineExpose:
defineExpose({ focus, prefill, send: (text: string) => onSubmit({ content: text }) })

Then in HomeView:

async function onQuickAction(query: string) {
  await chatPanelRef.value?.send(query)
}
  • Step 3: Replace the chat section template

Replace the <!-- ── Chat widget ─────────────────────────────────────────── --> section AND the <!-- ── Inline response ────────────────────────────────────── --> section with:

<!-- ── Chat widget ─────────────────────────────────────────── -->
<section class="chat-section">
  <div class="quick-actions">
    <button
      v-for="q in QUICK_ACTIONS"
      :key="q"
      class="quick-action-chip"
      :disabled="chatStore.streaming || !chatStore.chatReady"
      @click="onQuickAction(q)"
    >{{ q }}</button>
  </div>
  <ChatPanel
    ref="chatPanelRef"
    variant="widget"
    @conversation-started="onConversationStarted"
  />
</section>

Add onConversationStarted handler (optional — for any parent tracking):

function onConversationStarted(_convId: number) {
  // No-op — ChatPanel handles its own state
}
  • Step 4: Update ChatPanel.vue to expose send

In frontend/src/components/ChatPanel.vue, update defineExpose:

defineExpose({
  focus,
  prefill,
  send: (text: string) => onSubmit({ content: text }),
})
  • Step 5: Delete DashboardChatInput.vue
rm frontend/src/components/DashboardChatInput.vue
  • Step 6: TypeScript check
cd /path/to/project/frontend && npm run typecheck 2>&1 | grep -v "node_modules" | head -30

Expected: clean (no references to deleted DashboardChatInput anywhere).

  • Step 7: Commit
git add frontend/src/views/HomeView.vue frontend/src/components/ChatPanel.vue
git rm frontend/src/components/DashboardChatInput.vue
git commit -m "refactor: migrate HomeView widget to ChatPanel, delete DashboardChatInput"

Task 8: Final cleanup and TypeScript check

Files:

  • Check: all modified files

  • Step 1: Full TypeScript check

cd /path/to/project/frontend && npm run typecheck 2>&1 | grep -v "node_modules"

Expected: zero errors. Fix any remaining type errors before proceeding.

  • Step 2: Lint check
make lint

Expected: no new lint errors.

  • Step 3: Verify no orphaned imports
grep -r "DashboardChatInput" /path/to/project/frontend/src/ 2>/dev/null

Expected: no output.

grep -r "useVoiceRecorder\|useListenMode\|useStreamingTts\|useVoiceAudio" /path/to/project/frontend/src/views/ 2>/dev/null

Expected: output only from files that legitimately still use them (none — ChatPanel owns all these now). If any view still imports them, remove the import.

  • Step 4: Commit
git add -A
git commit -m "chore: final cleanup after ChatPanel unification"

Self-Review

Spec coverage check:

Spec requirement Covered by task
ChatPanel full + widget variants Task 3
ChatInputBar: textarea, note picker, PTT, send/abort Task 1
ChatStreamingBubble extraction Task 2
TTS / listen mode owned by ChatPanel Task 3
Context sidebar (full, non-briefing) Task 3
Scope chip (full, non-briefing, non-workspace) Task 3
Widget absorbs response display Task 3 (widget variant)
prefill() exposed for WorkspaceView chips Task 3 + Task 6
send() exposed for HomeView quick actions Task 7 Step 4
BriefingView readOnly for historical convs Task 5
ChatView sidebar stays in ChatView Task 4
DashboardChatInput deleted Task 7

Type consistency check:

  • ChatInputBar emits { content: string; contextNoteId?: number } — matches chatStore.sendMessage(content, contextNoteId?)
  • ChatPanel.onSubmit(payload) matches ChatInputBar emit type ✓
  • widgetFinalToolCalls typed as ToolCallRecord[] — imported from @/types/chat
  • defineExpose({ focus, prefill, send }) — all three are functions ✓
  • ChatPanel in WorkspaceView: projectId is number — matches props.projectId?: number