c89586dcd5
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2142 lines
70 KiB
Markdown
2142 lines
70 KiB
Markdown
# 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">×</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**
|
||
|
||
```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)">×</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**
|
||
|
||
```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 624–1003) 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` ✓
|