feat(chat): move scope chip to header, consolidate voice controls into input bar

Phase B of chat view refinement. Bumps chat reading column to 1200px,
lifts the RAG scope chip out of the footer and into the ChatView header
(next to the title), and collapses the listen toggle / volume slider /
stop-playback buttons into a single speaker popover on the input bar.
Listen mode + TTS are now available in briefing and workspace chats too,
since the controls live on the shared ChatInputBar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-15 09:44:16 -04:00
parent 035ec0c0dc
commit 4228e9a384
4 changed files with 290 additions and 239 deletions
+129 -1
View File
@@ -2,6 +2,7 @@
import { onMounted, onUnmounted, ref, computed, watch, nextTick } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useChatStore } from "@/stores/chat";
import { apiGet } from "@/api/client";
import ChatPanel from "@/components/ChatPanel.vue";
const route = useRoute();
@@ -14,6 +15,46 @@ const convSearchQuery = ref("");
const headerKebabOpen = ref(false);
const sidebarKebabOpen = ref(false);
// ── RAG scope chip ────────────────────────────────────────────────────────────
const projects = ref<{ id: number; title: string }[]>([]);
const scopeDropdownOpen = ref(false);
const scopePulse = ref(false);
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 id = store.currentConversation?.id;
if (!id) return;
await store.updateRagScope(id, value);
scopePulse.value = true;
setTimeout(() => { scopePulse.value = false; }, 600);
}
watch(
() => store.ragProjectId,
() => {
scopePulse.value = true;
setTimeout(() => { scopePulse.value = false; }, 600);
}
);
let prevConvId: number | null = null;
const convId = computed(() => {
@@ -78,6 +119,7 @@ const groupedConversations = computed((): ConvGroup[] => {
onMounted(async () => {
document.addEventListener("keydown", onGlobalKeydown);
document.addEventListener("mousedown", onDocumentMousedown);
loadProjects();
await store.fetchConversations();
if (convId.value) {
if (store.currentConversation?.id !== convId.value) {
@@ -207,14 +249,18 @@ function onDocumentMousedown(e: MouseEvent) {
if (sidebarKebabOpen.value && !target?.closest(".sidebar-kebab-wrapper")) {
sidebarKebabOpen.value = false;
}
if (scopeDropdownOpen.value && !target?.closest(".scope-chip-wrapper")) {
scopeDropdownOpen.value = false;
}
}
// ── Keyboard ──────────────────────────────────────────────────────────────────
function onGlobalKeydown(e: KeyboardEvent) {
if (e.key !== "Escape") return;
if (headerKebabOpen.value || sidebarKebabOpen.value) {
if (headerKebabOpen.value || sidebarKebabOpen.value || scopeDropdownOpen.value) {
headerKebabOpen.value = false;
sidebarKebabOpen.value = false;
scopeDropdownOpen.value = false;
return;
}
if (sidebarOpen.value) {
@@ -344,6 +390,36 @@ onUnmounted(() => {
</button>
<h2>{{ store.currentConversation.title || "New Chat" }}</h2>
<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 class="header-kebab-wrapper">
<button
class="btn-kebab"
@@ -588,6 +664,58 @@ onUnmounted(() => {
position: relative;
}
/* RAG scope chip (header) */
.scope-chip-wrapper { position: relative; flex-shrink: 0; }
.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);
right: 0;
min-width: 200px;
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; }
.kebab-menu {
position: absolute;
top: calc(100% + 4px);