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

2142 lines
70 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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**
```vue
<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**
```bash
cd /path/to/project/frontend && npm run typecheck 2>&1 | tail -20
```
Expected: no errors related to `ChatInputBar.vue`.
- [ ] **Step 3: Commit**
```bash
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`.
```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**
```bash
cd /path/to/project/frontend && npm run typecheck 2>&1 | tail -20
```
Expected: no errors.
- [ ] **Step 3: Commit**
```bash
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**
```vue
<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**
```bash
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**
```bash
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:
```typescript
// 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:
```typescript
import ChatPanel from "@/components/ChatPanel.vue";
```
- [ ] **Step 2: Remove refs/computed/functions that ChatPanel now owns**
Remove ALL of the following from `<script setup>`:
```typescript
// 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:
```typescript
// 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:
```typescript
const chatPanelRef = ref<InstanceType<typeof ChatPanel> | null>(null)
```
Remove `loadProjects()` from `onMounted`:
```typescript
// 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:
```vue
<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:
```css
.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**
```bash
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**
```bash
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:
```typescript
// 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:
```typescript
import ChatPanel from '@/components/ChatPanel.vue'
```
- [ ] **Step 2: Remove voice/TTS/scroll state and functions**
Remove:
```typescript
// 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:
```typescript
// 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):
```typescript
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:
```typescript
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`**
```typescript
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:
```typescript
// 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:
```vue
<!-- 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**
```bash
cd /path/to/project/frontend && npm run typecheck 2>&1 | grep -v "node_modules" | head -30
```
- [ ] **Step 7: Commit**
```bash
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:
```typescript
// 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:
```typescript
import ChatPanel from "@/components/ChatPanel.vue";
```
- [ ] **Step 2: Remove state and functions owned by ChatPanel**
Remove:
```typescript
// 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:
```typescript
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:
```vue
<!-- 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**
```bash
cd /path/to/project/frontend && npm run typecheck 2>&1 | grep -v "node_modules" | head -30
```
- [ ] **Step 5: Commit**
```bash
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:
```typescript
// REMOVE:
import DashboardChatInput from "@/components/DashboardChatInput.vue";
import ToolCallCard from "@/components/ToolCallCard.vue";
import type { ToolCallRecord, Message } from "@/types/chat";
```
Add:
```typescript
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>`:
```typescript
// 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:
```typescript
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:
```typescript
// In ChatPanel.vue, add to defineExpose:
defineExpose({ focus, prefill, send: (text: string) => onSubmit({ content: text }) })
```
Then in HomeView:
```typescript
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:
```vue
<!-- ── 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):
```typescript
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`:
```typescript
defineExpose({
focus,
prefill,
send: (text: string) => onSubmit({ content: text }),
})
```
- [ ] **Step 5: Delete DashboardChatInput.vue**
```bash
rm frontend/src/components/DashboardChatInput.vue
```
- [ ] **Step 6: TypeScript check**
```bash
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**
```bash
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**
```bash
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**
```bash
make lint
```
Expected: no new lint errors.
- [ ] **Step 3: Verify no orphaned imports**
```bash
grep -r "DashboardChatInput" /path/to/project/frontend/src/ 2>/dev/null
```
Expected: no output.
```bash
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**
```bash
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`