Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
9.4 KiB
ChatPanel Unification Design
Date: 2026-04-03
Goal
Replace the four divergent chat surfaces (ChatView, BriefingView, WorkspaceView, HomeView widget) with a single ChatPanel component that encapsulates all chat behaviour — streaming, TTS, PTT, tool calls, thinking blocks, abort — so that fixes and features automatically apply to every context.
Background
The app currently has four independent chat implementations that have drifted significantly:
| Surface | File | Gap |
|---|---|---|
| Main chat | ChatView.vue |
Canonical reference |
| Briefing | BriefingView.vue |
Had separate TTS impl (now fixed), no PTT, streaming race bug |
| Workspace | WorkspaceView.vue |
TTS missing until recently, different input wiring |
| Dashboard widget | HomeView.vue + DashboardChatInput.vue |
Separate input component, response rendered manually in parent, no TTS, no PTT |
Every fix to chat has required touching 3–4 files. This design makes chat a first-class component.
Architecture
Component: ChatPanel.vue
A single Vue 3 component that owns the entire chat interaction loop for a given conversation context. Two variants controlled by a variant prop:
full— full-height chat: message history, streaming bubble, input bar, all controlswidget— compact embedded chat: input bar + compact response area, no history scroll
Both variants share identical internals: same composables, same store reads, same TTS/PTT/abort logic.
Extracted Sub-components
| Component | Responsibility |
|---|---|
ChatInputBar.vue |
Unified input bar: textarea, note picker, PTT mic, send button, abort button |
ChatMessageList.vue |
Scrollable message history with auto-scroll, bulk-select (full variant only) |
ChatStreamingBubble.vue |
Live streaming content display + thinking block |
ChatToolCallList.vue |
Tool call cards, collapsed/expanded state |
State Ownership
ChatPanel reads from useChatStore directly — it does not accept messages or streaming state as props. This mirrors how all current views work and avoids prop-drilling re-implementation.
The conversation being displayed is controlled via a convId prop. When convId is undefined, ChatPanel uses chatStore.currentConversationId. The parent view sets up the conversation (creates it if needed) and passes the ID down.
Props & Emits Interface
interface ChatPanelProps {
variant: 'full' | 'widget'
convId?: number // which conversation to display; undefined = store current
projectId?: number // workspace: pins RAG scope, passed to sendMessage
briefingMode?: boolean // briefing: hides RAG scope chip, enables briefing-specific send path
placeholder?: string // input placeholder text
autoFocus?: boolean // focus input on mount
}
interface ChatPanelEmits {
// Emitted when a new conversation is started from the widget (so parent can track convId)
(e: 'conversation-started', convId: number): void
}
All other behaviour (TTS, PTT, thinking, tool calls, streaming indicator, abort) is always on — not gated by props. The intentional differences between views are expressed only through the props above.
Variant Behaviour
variant="full" (ChatView, BriefingView, WorkspaceView)
Layout (top to bottom):
┌────────────────────────────────────────┐
│ [RAG scope chip / briefing header] │ ← shown unless briefingMode or projectId set
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
│ ChatMessageList │
│ user bubble │
│ assistant bubble + tool calls │
│ thinking block (always shown) │
│ ... │
│ ChatStreamingBubble (while streaming) │
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
│ ChatInputBar │
│ [textarea] [note-picker] [mic] [▶] │
│ [listen toggle] [abort] │
└────────────────────────────────────────┘
variant="widget" (HomeView dashboard)
Layout (top to bottom, compact):
┌────────────────────────────────────────┐
│ ChatInputBar (pill style) │
│ [textarea] [mic] [▶] │
├────────────────────────────────────────┤
│ [query text] (after send) │
│ [streaming / final response text] │
│ [tool call chips] │
│ [Continue in Chat →] │
└────────────────────────────────────────┘
The widget variant does NOT show full message history. It shows only the most recent exchange. Once a new conversation is started or the user navigates to /chat/:id, the full history is available.
The .dashboard-response section currently in HomeView.vue moves inside ChatPanel and is rendered when variant="widget" and a conversation exists.
TTS / PTT Wiring
ChatPanel instantiates useStreamingTts and useListenMode internally. These are not passed as props.
// Inside ChatPanel setup()
const listenMode = useListenMode()
const voiceTtsEnabled = computed(() => /* same check as current views */)
const tts = useStreamingTts({
streamingContent: computed(() => chatStore.streamingContent),
streaming: computed(() => !!chatStore.streaming),
enabled: computed(() => listenMode.value && voiceTtsEnabled.value),
})
PTT is handled inside ChatInputBar via the existing useVoiceRecorder composable (already used in DashboardChatInput). On recording stop, the transcribed text is placed in the textarea and auto-submitted.
Per-View Migration
ChatView → <ChatPanel variant="full">
- Remove: all TTS/PTT/streaming/abort logic, scroll management, input bar template
- Keep: route wiring, conversation list sidebar, bulk-delete UI (sidebar stays in ChatView)
- ChatPanel replaces only the right-hand panel
BriefingView → <ChatPanel variant="full" briefingMode />
- Remove: streaming watch, TTS, manual scroll, input bar, response persistence workaround
- Keep: history dropdown (today / past briefings), date header
briefingModehides the RAG scope chip
WorkspaceView → <ChatPanel variant="full" :projectId="projectId">
- Remove: inline chat input, streaming watch, TTS wiring
- Keep: 3-panel grid layout, task panel, note editor panel
- ChatPanel takes the centre column
HomeView → <ChatPanel variant="widget">
- Remove:
DashboardChatInputimport + usage,.dashboard-responsesection, all manual store wiring (dashboardConvId,dashboardQuery,dashboardFinalContent,dashboardFinalToolCalls,onChatSubmit) - Keep: dashboard layout, projects/tasks/events sections
DashboardChatInput.vuedeleted
Data Flow
Parent view
└─ <ChatPanel :convId="convId" variant="full|widget">
├─ reads: useChatStore (messages, streaming, streamingContent, currentConversation)
├─ ChatMessageList — renders history from store
├─ ChatStreamingBubble — renders chatStore.streamingContent while streaming
├─ ChatToolCallList — renders tool calls from streaming + finalized messages
├─ ChatInputBar
│ ├─ usePtt (mic → textarea → auto-send)
│ └─ emits: submit(content, contextNoteId)
├─ useStreamingTts (sentence-chunk TTS during streaming)
└─ useListenMode (shared global toggle)
Files Created / Modified
Created:
frontend/src/components/ChatPanel.vuefrontend/src/components/ChatInputBar.vuefrontend/src/components/ChatMessageList.vuefrontend/src/components/ChatStreamingBubble.vue- (no new composable needed — PTT uses existing
useVoiceRecorder.ts)
Modified:
frontend/src/views/ChatView.vue— use ChatPanel for the chat areafrontend/src/views/BriefingView.vue— replace chat section with ChatPanelfrontend/src/views/WorkspaceView.vue— replace inline chat with ChatPanelfrontend/src/views/HomeView.vue— replace DashboardChatInput + response section with ChatPanel widget
Deleted:
frontend/src/components/DashboardChatInput.vue
CSS / Styling
ChatPanelcarries its own scoped CSS for both variantsChatInputBarreplicates the pill style currently inDashboardChatInputand the flat style inChatView— variant is controlled by apillboolean prop (default false; widget sets it true)- All existing UI design language tokens (
--color-primary,--radius-lg, Fraunces labels, gradient send button) are preserved
What Does NOT Change
- Chat store (
useChatStore) — unchanged - API client (
client.ts) — unchanged - Backend routes — unchanged
- WorkspaceTaskPanel and WorkspaceNoteEditor — unchanged
- Briefing history dropdown and date header — unchanged
- ChatView conversation sidebar and bulk-delete — unchanged
- RAG scope chip logic — moved inside ChatPanel, behaviour identical