feat: migrate KnowledgeView mini-chat to ChatPanel + enable note picker everywhere

- KnowledgeView: replace custom mini-chat (voice, PTT, manual scroll, message
  rendering) with ChatPanel variant="full"; gains RAG scope chip, TTS listen
  mode, volume control, and note picker automatically (-218 lines)
- ChatInputBar: remove briefingMode guard on note picker so attach/search works
  in briefing, workspace, widget, and knowledge chat surfaces

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-03 20:16:36 -04:00
parent d624d38412
commit eda9c5ce43
2 changed files with 74 additions and 292 deletions
+1 -1
View File
@@ -160,7 +160,7 @@ defineExpose({ focus, prefill })
<div class="input-row">
<!-- Note picker -->
<div v-if="!briefingMode" class="note-picker-wrapper">
<div class="note-picker-wrapper">
<button
class="btn-icon"
@click="toggleNotePicker"
+73 -291
View File
@@ -1,17 +1,15 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, nextTick, watchEffect } from "vue";
import { ref, watch, onMounted, onUnmounted, nextTick, watchEffect } from "vue";
import { useRouter } from "vue-router";
import { apiGet, apiPatch, transcribeAudio } from "@/api/client";
import { apiGet, apiPatch } from "@/api/client";
import { fmtCompact } from "@/utils/dateFormat";
import { renderMarkdown } from "@/utils/markdown";
import { useChatStore } from "@/stores/chat";
import { useSettingsStore } from "@/stores/settings";
import { useVoiceRecorder } from "@/composables/useVoiceRecorder";
import GraphView from "@/views/GraphView.vue";
import ChatPanel from "@/components/ChatPanel.vue";
import ChatInputBar from "@/components/ChatInputBar.vue";
const router = useRouter();
const chatStore = useChatStore();
const settingsStore = useSettingsStore();
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -154,58 +152,20 @@ function toggleGraphExpand() {
// ─── Mini-chat widget ─────────────────────────────────────────────────────────
const chatInput = ref("");
const chatOpen = ref(false);
const chatCollapsed = ref(false);
const chatConvId = ref<number | null>(null);
const chatSending = ref(false);
const chatMessages = computed(() => {
if (!chatConvId.value) return [];
const conv = chatStore.currentConversation;
if (!conv || conv.id !== chatConvId.value) return [];
return conv.messages ?? [];
});
const chatScrollEl = ref<HTMLElement | null>(null);
const chatInputEl = ref<HTMLTextAreaElement | null>(null);
const voiceEnabled = computed(() => settingsStore.voiceSttReady);
const transcribingVoice = ref(false);
const recorder = useVoiceRecorder();
async function sendChatMessage() {
const text = chatInput.value.trim();
if (!text || chatSending.value || chatStore.streaming) return;
chatOpen.value = true;
// Create a conversation on first message
async function onMinichatSubmit(payload: { content: string; contextNoteId?: number }) {
if (!chatConvId.value) {
try {
const conv = await chatStore.createConversation("Quick chat");
chatConvId.value = conv.id;
await chatStore.fetchConversation(conv.id);
} catch { return; }
const conv = await chatStore.createConversation("Knowledge chat");
chatConvId.value = conv.id;
await chatStore.fetchConversation(conv.id);
} else if (chatStore.currentConversation?.id !== chatConvId.value) {
await chatStore.fetchConversation(chatConvId.value);
}
chatInput.value = "";
chatSending.value = true;
try {
await chatStore.sendMessage(text);
await nextTick();
scrollChatToBottom();
} catch { /* silent */ } finally {
chatSending.value = false;
}
}
function scrollChatToBottom() {
nextTick(() => {
if (chatScrollEl.value) {
chatScrollEl.value.scrollTop = chatScrollEl.value.scrollHeight;
}
});
chatOpen.value = true;
chatCollapsed.value = false;
await chatStore.sendMessage(payload.content, payload.contextNoteId, undefined, true);
}
// ─── Auto-refresh cards when chat creates/edits notes or tasks ───────────────
@@ -232,50 +192,12 @@ watch(
);
watch(() => chatStore.streaming, (streaming) => {
if (!streaming) {
scrollChatToBottom();
processedToolCalls.value = 0;
}
if (!streaming) processedToolCalls.value = 0;
});
async function closeChat() {
function closeChat() {
chatOpen.value = false;
chatConvId.value = null;
chatInput.value = "";
}
function onChatKeydown(e: KeyboardEvent) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendChatMessage();
}
}
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()) {
chatInput.value = transcript.trim();
await sendChatMessage();
}
} catch { /* silent */ } finally {
transcribingVoice.value = false;
}
}
function chatAutoResize() {
const el = chatInputEl.value;
if (!el) return;
el.style.height = "auto";
el.style.height = Math.min(el.scrollHeight, 120) + "px";
}
// ─── List item toggle ─────────────────────────────────────────────────────────
@@ -357,10 +279,7 @@ onMounted(() => {
fetchItems(true);
fetchTags();
fetchTodayBar();
nextTick(() => {
chatInputEl.value?.focus();
setupScrollObserver();
});
nextTick(() => setupScrollObserver());
});
onUnmounted(() => {
@@ -571,81 +490,36 @@ watchEffect(() => {
<!-- Floating mini-chat -->
<div class="minichat" :class="{ 'minichat--open': chatOpen }">
<!-- Message history (shown when chatOpen and not collapsed) -->
<div v-if="chatOpen && !chatCollapsed" class="minichat-messages" ref="chatScrollEl">
<template v-if="chatMessages.length === 0 && !chatStore.streaming">
<div class="minichat-welcome">Ask me anything about your notes, tasks, or ideas.</div>
</template>
<div
v-for="msg in chatMessages"
:key="msg.id"
class="minichat-msg"
:class="msg.role === 'user' ? 'msg--user' : 'msg--assistant'"
>
<div class="msg-content" v-html="msg.role === 'assistant' ? renderMarkdown(msg.content) : msg.content"></div>
</div>
<div v-if="chatStore.streaming" class="minichat-msg msg--assistant">
<div class="msg-content streaming-indicator">
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
</div>
<!-- Header with controls only when chat is open -->
<div v-if="chatOpen" class="minichat-header">
<span class="minichat-title">Chat</span>
<div class="minichat-header-actions">
<button
class="btn-icon-sm"
@click="chatCollapsed = !chatCollapsed"
:title="chatCollapsed ? 'Expand' : 'Collapse'"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline v-if="chatCollapsed" points="18 15 12 9 6 15"/>
<polyline v-else points="6 9 12 15 18 9"/>
</svg>
</button>
<button class="btn-icon-sm" @click="closeChat" title="Close chat"></button>
</div>
</div>
<!-- Input bar matches shared chat-input-bar pattern -->
<div class="minichat-input-row">
<div class="chat-input-bar">
<textarea
ref="chatInputEl"
v-model="chatInput"
@keydown="onChatKeydown"
@input="chatAutoResize"
placeholder="Ask anything…"
:disabled="chatStore.streaming"
rows="1"
></textarea>
<button
v-if="voiceEnabled && recorder.isSupported"
class="btn-attach btn-mic-ptt"
:class="{ 'mic-recording': recorder.recording.value, 'mic-transcribing': transcribingVoice }"
@mousedown.prevent="startPtt"
@mouseup.prevent="stopPtt"
@touchstart.prevent="startPtt"
@touchend.prevent="stopPtt"
:disabled="transcribingVoice"
title="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>
<button
class="btn-send"
:disabled="!chatInput.trim() || chatSending || chatStore.streaming"
@click="sendChatMessage"
title="Send"
>&uarr;</button>
</div>
<button
v-if="chatOpen"
class="btn-close-chat"
@click="chatCollapsed = !chatCollapsed"
:title="chatCollapsed ? 'Expand chat' : 'Collapse chat'"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline v-if="chatCollapsed" points="18 15 12 9 6 15"/>
<polyline v-else points="6 9 12 15 18 9"/>
</svg>
</button>
<button
v-if="chatOpen"
class="btn-close-chat"
@click="closeChat"
title="Close chat"
></button>
<!-- Full ChatPanel when open, not collapsed, conversation exists -->
<div v-if="chatOpen && !chatCollapsed && chatConvId" class="minichat-chat-panel">
<ChatPanel variant="full" placeholder="Ask anything…" />
</div>
<!-- Standalone input bar when closed or collapsed -->
<div v-if="!chatOpen || chatCollapsed" class="minichat-input-row">
<ChatInputBar
placeholder="Ask anything…"
@submit="onMinichatSubmit"
@abort="chatStore.cancelGeneration()"
/>
</div>
</div>
@@ -1083,7 +957,10 @@ watchEffect(() => {
background: var(--color-bg-secondary);
border-radius: 16px 16px 0 0;
box-shadow: 0 -8px 32px rgba(0,0,0,0.35), 0 -1px 0 rgba(255,255,255,0.05);
transition: box-shadow 0.2s;
transition: height 0.2s ease;
}
.minichat--open {
height: 500px;
}
.knowledge-root.graph-open .minichat {
right: 500px;
@@ -1092,140 +969,45 @@ watchEffect(() => {
.knowledge-root.graph-open.graph-expanded .minichat {
right: min(960px, 60vw);
}
.minichat-messages {
max-height: 360px;
overflow-y: auto;
padding: 12px 16px;
/* Header row (collapse / close buttons) */
.minichat-header {
display: flex;
flex-direction: column;
gap: 8px;
background: transparent;
border-bottom: 1px solid rgba(255,255,255,0.05);
border-radius: 16px 16px 0 0;
align-items: center;
justify-content: space-between;
padding: 8px 14px;
border-bottom: 1px solid rgba(255,255,255,0.06);
flex-shrink: 0;
}
.minichat-welcome {
.minichat-title {
font-size: 0.82rem;
font-weight: 600;
color: var(--color-muted);
font-size: 0.85rem;
text-align: center;
padding: 20px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.minichat-msg { max-width: 85%; }
.msg--user {
align-self: flex-end;
background: rgba(99,102,241,0.12);
border: 1px solid rgba(99,102,241,0.2);
border-radius: 12px 12px 2px 12px;
padding: 8px 12px;
font-size: 0.88rem;
}
.msg--assistant {
align-self: flex-start;
border-left: 2px solid var(--color-primary, #6366f1);
padding: 8px 12px;
font-size: 0.88rem;
background: rgba(255,255,255,0.02);
border-radius: 0 12px 12px 2px;
}
.msg-content { line-height: 1.5; white-space: pre-wrap; }
.streaming-indicator {
.minichat-header-actions {
display: flex;
gap: 4px;
align-items: center;
padding: 4px 0;
}
.dot {
width: 6px; height: 6px;
border-radius: 50%;
background: var(--color-primary, #6366f1);
animation: dot-pulse 1.2s ease-in-out infinite;
}
.dot:nth-child(2) { animation-delay: 0.2s; }
.dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes dot-pulse {
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
40% { opacity: 1; transform: scale(1); }
}
/* ChatPanel body — fills remaining space */
.minichat-chat-panel {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.minichat-chat-panel :deep(.chat-body) {
flex: 1;
min-height: 0;
}
/* Standalone input row (closed / collapsed state) */
.minichat-input-row {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
}
/* Reuse the shared chat-input-bar pattern */
.chat-input-bar {
flex: 1;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.5rem 0.5rem 0.75rem;
background: var(--color-input-bar-bg);
border-radius: 20px;
box-shadow: 0 2px 12px var(--color-shadow);
}
.chat-input-bar textarea {
flex: 1;
resize: none;
padding: 0.4rem 0.5rem;
border: none;
border-radius: 12px;
font-family: inherit;
font-size: 0.95rem;
background: transparent;
color: var(--color-input-bar-text);
outline: none;
max-height: 120px;
overflow-y: auto;
}
.chat-input-bar textarea::placeholder { color: var(--color-input-bar-placeholder); }
.chat-input-bar textarea:disabled { opacity: 0.5; }
.btn-attach {
background: none;
border: none;
cursor: pointer;
color: var(--color-input-bar-text);
opacity: 0.6;
padding: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
}
.btn-attach:hover { opacity: 1; }
.btn-attach:disabled { opacity: 0.3; cursor: default; }
.btn-mic-ptt { transition: color 0.15s, opacity 0.15s; }
.btn-mic-ptt.mic-recording { color: #ef4444 !important; opacity: 1 !important; }
.btn-mic-ptt.mic-transcribing { color: var(--color-primary); opacity: 1 !important; }
.btn-send {
width: 34px;
min-width: 34px;
height: 34px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: 50%;
cursor: pointer;
font-size: 1.1rem;
flex-shrink: 0;
}
.btn-send:disabled { opacity: 0.35; cursor: default; }
.btn-close-chat {
flex-shrink: 0;
background: none;
border: 1px solid var(--color-border, rgba(255,255,255,0.1));
border-radius: 8px;
color: var(--color-muted);
cursor: pointer;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8rem;
transition: color 0.15s;
}
.btn-close-chat:hover { color: var(--color-text); }
</style>