Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
70 KiB
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.vuealready exists but is orphaned (zero usages). Overwrite it completely.InlineAssistPanel.vueandHistoryPanel.vueare also orphaned — ignore them.chatStore.sendMessage(content, contextNoteId?, includeNoteIds?, think?, contextNoteTitle?, excludeNoteIds?, ragProjectId?, workspaceProjectId?)— always passthink: 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(ormake typecheckfrom 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">×</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"
>↑</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
ragProjectIdandworkspaceProjectId=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)">×</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)">×</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
sendMessageandonMounted
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 624–1003) 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
sendanddiscussArticle
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-centertemplate 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:
ChatInputBaremits{ content: string; contextNoteId?: number }— matcheschatStore.sendMessage(content, contextNoteId?)✓ChatPanel.onSubmit(payload)matches ChatInputBar emit type ✓widgetFinalToolCallstyped asToolCallRecord[]— imported from@/types/chat✓defineExpose({ focus, prefill, send })— all three are functions ✓ChatPanelin WorkspaceView:projectIdisnumber— matchesprops.projectId?: number✓