Files
FabledScribe/frontend/src/views/ChatView.vue
T
bvandeusen 71ca0ecb5c fix: voice status global store, per-view mic reactivity, single-voice preview
- Move voice status into settings store (voiceSttReady, voiceTtsReady),
  checked once at login and refreshed after admin model reload
- ChatView, BriefingView, DashboardChatInput now use computed refs from
  the store — mic buttons appear reactively without needing a page reload
- BriefingView: separate STT-only guard for mic PTT vs TTS-only guard for
  listen mode / speak buttons
- Add ▶ Preview button to Voice & Speed section in Settings for single-
  voice testing without enabling blend mode

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 20:53:06 -04:00

1855 lines
52 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { onMounted, onUnmounted, ref, computed, watch, nextTick } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useChatStore } from "@/stores/chat";
import { useSettingsStore } from "@/stores/settings";
import { apiGet, transcribeAudio } from "@/api/client";
import { renderMarkdown } from "@/utils/markdown";
import { useVoiceRecorder } from "@/composables/useVoiceRecorder";
import ChatMessage from "@/components/ChatMessage.vue";
import ToolCallCard from "@/components/ToolCallCard.vue";
import ToolConfirmCard from "@/components/ToolConfirmCard.vue";
import type { Note } from "@/types/note";
const route = useRoute();
const router = useRouter();
const store = useChatStore();
const settingsStore = useSettingsStore();
const messageInput = ref("");
const messagesEl = ref<HTMLElement | null>(null);
const inputEl = ref<HTMLTextAreaElement | null>(null);
const sending = ref(false);
const summarizing = ref(false);
const sidebarOpen = ref(false);
// Voice PTT — use store so status is globally reactive
const transcribingVoice = ref(false);
const recorder = useVoiceRecorder();
const voiceEnabled = computed(() => settingsStore.voiceSttReady);
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();
autoResize();
await sendMessage();
}
} catch { /* silent */ }
finally { transcribingVoice.value = false; }
}
// Research modal state
const showResearchModal = ref(false);
const researchTopic = ref("");
// Note picker state
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;
// Explicitly included notes (user clicked "+ include" in sidebar)
const includedNoteIds = ref<Set<number>>(new Set());
const includedNotes = ref<{ id: number; title: string }[]>([]);
// Suggested notes — auto-found by search, not yet included (score 0.450.59)
const suggestedNotes = ref<{ id: number; title: string; score?: number | null }[]>([]);
// Auto-injected notes — injected automatically because score >= 0.60
const autoInjectedNotes = ref<{ id: number; title: string; score?: number | null }[]>([]);
// Note IDs excluded from auto-injection on next message
const excludedNoteIds = ref<number[]>([]);
// Scope chip state
const scopeDropdownOpen = ref(false);
const projects = ref<{ id: number; title: string }[]>([]);
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;
if (!convId.value) return;
await store.updateRagScope(convId.value, value);
scopePulse.value = true;
setTimeout(() => { scopePulse.value = false; }, 600);
}
let prevConvId: number | null = null;
const convId = computed(() => {
const id = route.params.id;
return id ? Number(id) : null;
});
function formatConvDate(dateStr: string): string {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMin = Math.floor(diffMs / 60_000);
const diffHrs = Math.floor(diffMs / 3_600_000);
if (diffMin < 1) return "Just now";
if (diffMin < 60) return `${diffMin}m ago`;
if (diffHrs < 10) return `${diffHrs}h ago`;
if (date.getFullYear() === now.getFullYear()) {
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
return date.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
}
interface ConvGroup { label: string; convs: typeof store.conversations }
const groupedConversations = computed((): ConvGroup[] => {
const now = new Date();
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const startOfYesterday = new Date(startOfToday.getTime() - 86_400_000);
const startOfWeek = new Date(startOfToday.getTime() - 6 * 86_400_000);
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const buckets: Record<string, typeof store.conversations> = {};
const order: string[] = [];
for (const conv of store.conversations) {
const d = new Date(conv.updated_at);
let key: string;
if (d >= startOfToday) {
key = "Today";
} else if (d >= startOfYesterday) {
key = "Yesterday";
} else if (d >= startOfWeek) {
key = "This week";
} else if (d >= startOfMonth) {
key = "This month";
} else {
// Sub-group older by "Month Year"
key = d.toLocaleDateString(undefined, { month: "long", year: "numeric" });
}
if (!buckets[key]) { buckets[key] = []; order.push(key); }
buckets[key].push(conv);
}
return order.map((label) => ({ label, convs: buckets[label] }));
});
const streamingRendered = computed(() => {
if (!store.streamingContent) return "";
return renderMarkdown(store.streamingContent);
});
const inputPlaceholder = computed(() => {
if (!store.chatReady) return "Chat unavailable";
if (store.streaming) return "Type to queue next message… (Enter to queue)";
return "Type a message… (Enter to send, Shift+Enter for new line)";
});
onMounted(async () => {
document.addEventListener("keydown", onGlobalKeydown);
await Promise.all([store.fetchConversations(), loadProjects()]);
if (convId.value) {
if (store.currentConversation?.id !== convId.value) {
await store.fetchConversation(convId.value);
}
// Reconnect if the last assistant message is still generating (e.g. after page refresh)
store.reconnectIfGenerating(convId.value);
} else {
await startNewConversation();
}
nextTick(() => inputEl.value?.focus());
});
watch(convId, async (newId) => {
// Clean up empty previous conversation
if (prevConvId && prevConvId !== newId) {
const prev = store.conversations.find((c) => c.id === prevConvId);
if (prev && prev.message_count === 0) {
store.deleteConversation(prevConvId);
}
}
prevConvId = newId ?? null;
includedNoteIds.value = new Set();
includedNotes.value = [];
suggestedNotes.value = [];
autoInjectedNotes.value = [];
excludedNoteIds.value = [];
if (newId) {
// Skip re-fetch if this conversation is already loaded or still streaming
// (avoids a stale GET overwriting in-progress messages)
if (store.currentConversation?.id !== newId && !store.isStreamingConv(newId)) {
await store.fetchConversation(newId);
}
// Reconnect if a prior stream died while the backend was still generating
store.reconnectIfGenerating(newId);
scrollToBottom();
} else {
await startNewConversation();
}
nextTick(() => inputEl.value?.focus());
});
watch(
() => store.streamingContent,
() => scrollToBottom()
);
watch(
() => store.lastContextMeta,
(meta) => {
if (!meta) 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));
// Process auto_injected_notes (explicitly marked as injected)
const injectedFromMeta = meta.auto_injected_notes ?? [];
for (const note of injectedFromMeta) {
if (!alreadyAutoInjected.has(note.id) && !alreadyIncluded.has(note.id)) {
autoInjectedNotes.value.push(note);
alreadyAutoInjected.add(note.id);
}
}
// Process auto_notes — auto_injected flag separates injected from suggested
const autoNotes = meta.auto_notes ?? [];
for (const note of autoNotes) {
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 scrollToBottom() {
nextTick(() => {
if (messagesEl.value) {
messagesEl.value.scrollTop = messagesEl.value.scrollHeight;
}
});
}
function toggleSidebar() {
sidebarOpen.value = !sidebarOpen.value;
}
async function selectConversation(id: number) {
sidebarOpen.value = false;
router.push(`/chat/${id}`);
}
async function startNewConversation() {
const conv = await store.createConversation();
router.push(`/chat/${conv.id}`);
// watch(convId) will fire and fetch the conversation
}
async function newConversation() {
await startNewConversation();
}
// Bulk selection state
const selectMode = ref(false);
const selectedIds = ref<Set<number>>(new Set());
const bulkConfirm = ref(false);
const bulkDeleting = ref(false);
function toggleSelectMode() {
selectMode.value = !selectMode.value;
selectedIds.value = new Set();
bulkConfirm.value = false;
}
function toggleSelectConv(id: number) {
if (selectedIds.value.has(id)) {
selectedIds.value.delete(id);
} else {
selectedIds.value.add(id);
}
selectedIds.value = new Set(selectedIds.value); // trigger reactivity
}
function selectAll() {
selectedIds.value = new Set(store.conversations.map((c) => c.id));
}
function clearSelection() {
selectedIds.value = new Set();
bulkConfirm.value = false;
}
async function bulkDelete() {
if (!bulkConfirm.value) { bulkConfirm.value = true; return; }
bulkDeleting.value = true;
try {
const ids = [...selectedIds.value];
await store.bulkDeleteConversations(ids);
if (ids.includes(convId.value ?? -1)) router.push("/chat");
selectedIds.value = new Set();
bulkConfirm.value = false;
selectMode.value = false;
} finally {
bulkDeleting.value = false;
}
}
async function removeConversation(id: number) {
await store.deleteConversation(id);
if (convId.value === id) {
router.push("/chat");
}
}
async function sendMessage() {
const content = messageInput.value.trim();
if (!content) return;
// Auto-create conversation if none selected
if (!store.currentConversation) {
const conv = await store.createConversation();
await store.fetchConversation(conv.id);
router.push(`/chat/${conv.id}`);
}
sending.value = true;
messageInput.value = "";
resetTextareaHeight();
scrollToBottom();
await store.sendMessage(
content,
undefined,
includedNoteIds.value.size ? [...includedNoteIds.value] : undefined,
true, // enable thinking in the full chat view
undefined,
excludedNoteIds.value.length ? excludedNoteIds.value : undefined,
store.ragProjectId,
);
sending.value = false;
// Refresh conversation list to show updated title/timestamps
store.fetchConversations();
scrollToBottom();
nextTick(() => inputEl.value?.focus());
}
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");
}
}
async function handleSummarize() {
if (!store.currentConversation || summarizing.value) return;
summarizing.value = true;
try {
await store.summarizeAsNote(store.currentConversation.id);
const { useToastStore } = await import("@/stores/toast");
useToastStore().show("Conversation summarized and saved as note");
} catch {
const { useToastStore } = await import("@/stores/toast");
useToastStore().show("Failed to summarize", "error");
} finally {
summarizing.value = false;
}
}
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();
sendMessage();
}
}
// Research modal
function toggleResearchModal() {
showResearchModal.value = !showResearchModal.value;
if (showResearchModal.value) {
researchTopic.value = "";
nextTick(() => {
const el = document.querySelector(".research-topic-input") as HTMLInputElement;
el?.focus();
});
}
}
function submitResearch() {
const topic = researchTopic.value.trim();
if (!topic) return;
messageInput.value = `Research: ${topic}`;
showResearchModal.value = false;
researchTopic.value = "";
sendMessage();
}
// Note picker
function toggleNotePicker() {
showNotePicker.value = !showNotePicker.value;
if (showNotePicker.value) {
noteSearchQuery.value = "";
noteSearchResults.value = [];
nextTick(() => {
const el = document.querySelector(".note-picker-search") as HTMLInputElement;
el?.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 }) {
includeNote(note);
showNotePicker.value = false;
}
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);
}
}
// Keyboard shortcuts
function onGlobalKeydown(e: KeyboardEvent) {
if (e.key !== "Escape") return;
// Close research modal first
if (showResearchModal.value) {
showResearchModal.value = false;
return;
}
// Close note picker
if (showNotePicker.value) {
showNotePicker.value = false;
return;
}
// Close mobile sidebar overlay
if (sidebarOpen.value) {
sidebarOpen.value = false;
return;
}
// If textarea is focused and has content, clear it (don't navigate away)
if (document.activeElement === inputEl.value && messageInput.value.trim()) {
messageInput.value = "";
resetTextareaHeight();
return;
}
// Navigate home
router.push("/");
}
onUnmounted(() => {
document.removeEventListener("keydown", onGlobalKeydown);
// Clean up empty conversation when navigating away from the chat view entirely
if (prevConvId) {
const conv = store.conversations.find((c) => c.id === prevConvId);
if (conv && conv.message_count === 0) {
store.deleteConversation(prevConvId);
}
}
});
</script>
<template>
<main class="chat-page">
<div
v-if="sidebarOpen"
class="sidebar-overlay"
@click="sidebarOpen = false"
></div>
<aside class="chat-sidebar" :class="{ open: sidebarOpen }">
<div class="sidebar-top-bar">
<button class="btn-new-conv" @click="newConversation">+ New Chat</button>
<button
class="btn-select-mode"
:class="{ active: selectMode }"
@click="toggleSelectMode"
title="Select conversations"
>Select</button>
</div>
<!-- Bulk action bar -->
<div v-if="selectMode" class="bulk-bar">
<span class="bulk-count">{{ selectedIds.size }} selected</span>
<button class="bulk-link" @click="selectAll">All</button>
<button class="bulk-link" @click="clearSelection">None</button>
<button
v-if="selectedIds.size"
class="bulk-delete-btn"
:class="{ confirm: bulkConfirm }"
:disabled="bulkDeleting"
@click="bulkDelete"
>
{{ bulkDeleting ? '...' : bulkConfirm ? `Delete ${selectedIds.size}?` : `Delete (${selectedIds.size})` }}
</button>
</div>
<div class="conv-list">
<template v-for="group in groupedConversations" :key="group.label">
<div class="conv-group-label">{{ group.label }}</div>
<div
v-for="conv in group.convs"
:key="conv.id"
class="conv-item"
:class="{ active: convId === conv.id, selected: selectedIds.has(conv.id) }"
@click="selectMode ? toggleSelectConv(conv.id) : selectConversation(conv.id)"
>
<input
v-if="selectMode"
type="checkbox"
class="conv-checkbox"
:checked="selectedIds.has(conv.id)"
@click.stop="toggleSelectConv(conv.id)"
/>
<div class="conv-info">
<span class="conv-title">{{ conv.title || "Untitled" }}</span>
<span class="conv-date">{{ formatConvDate(conv.updated_at) }}</span>
</div>
<button
v-if="!selectMode"
class="btn-delete-conv"
@click.stop="removeConversation(conv.id)"
title="Delete conversation"
>
&times;
</button>
</div>
</template>
<p v-if="!store.conversations.length" class="empty-msg">
No conversations yet
</p>
</div>
</aside>
<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>
<button
v-if="store.streaming"
class="btn-abort"
@click="store.cancelGeneration()"
title="Stop generation"
>
Stop
</button>
<button
v-if="store.currentConversation.messages.length"
class="btn-summarize"
@click="handleSummarize"
:disabled="summarizing || store.streaming"
>
{{ summarizing ? "Summarizing..." : "Summarize as Note" }}
</button>
</div>
<div class="chat-body">
<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 message (assistant typing) -->
<div v-if="store.streaming" 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" class="typing-indicator"></span>
</div>
</div>
<!-- Queued messages shown as pending bubbles -->
<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()" aria-label="Cancel queued messages">
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 -->
<aside v-if="autoInjectedNotes.length || includedNotes.length || suggestedNotes.length" class="context-sidebar">
<!-- AUTO-INCLUDED section -->
<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 score: ${note.score}`"
>{{ Math.round(note.score * 100) }}%</span>
<button class="context-note-remove" @click="excludeAutoNote(note.id)" title="Exclude from auto-injection">&times;</button>
</div>
</template>
<!-- SUGGESTED section -->
<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,
}"
:title="`Relevance score: ${note.score}`"
>{{ Math.round(note.score * 100) }}%</span>
<button class="context-note-add" @click="includeNote(note)" title="Add to context">+</button>
</div>
</template>
<!-- IN CONTEXT section -->
<template v-if="includedNotes.length">
<div class="context-sidebar-header" :class="{ 'context-sidebar-header-gap': autoInjectedNotes.length || suggestedNotes.length }">In Context</div>
<!-- Explicitly included notes -->
<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)" title="Remove from context">&times;</button>
</div>
</template>
</aside>
</div>
<div class="input-wrapper">
<!-- Scope chip above input -->
<div 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>
<div class="input-area">
<!-- Research button -->
<div class="research-wrapper">
<button
class="btn-attach"
@click="toggleResearchModal"
:disabled="store.streaming || !store.chatReady"
title="Research a topic"
>
<svg width="18" height="18" 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>
<!-- Research modal -->
<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>
<!-- Note picker button -->
<div class="note-picker-wrapper">
<button
class="btn-attach"
@click="toggleNotePicker"
:disabled="store.streaming || !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>
<!-- Note picker dropdown -->
<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>
<!-- Mic PTT button -->
<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 || !store.chatReady"
:title="recorder.recording.value ? 'Release to send' : 'Hold to speak'"
aria-label="Push to talk"
>
<svg v-if="!transcribingVoice" width="18" height="18" 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="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/>
</svg>
</button>
<textarea
ref="inputEl"
v-model="messageInput"
@keydown="onInputKeydown"
@input="autoResize"
:placeholder="inputPlaceholder"
:disabled="!store.chatReady"
rows="1"
></textarea>
<button
v-if="store.streaming"
class="btn-stop"
@click="store.cancelGeneration()"
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="sendMessage"
:disabled="!messageInput.trim() || !store.chatReady"
>
&uarr;
</button>
</div>
</div>
</template>
<div v-else class="no-conversation">
<button class="btn-sidebar-toggle no-conv-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>
<p>Select a conversation or start a new chat.</p>
<button class="btn-new-conv" @click="newConversation">
+ New Chat
</button>
</div>
</section>
</main>
</template>
<style scoped>
.chat-page {
display: flex;
height: 100%;
overflow: hidden;
}
.chat-sidebar {
width: 260px;
min-width: 200px;
display: flex;
flex-direction: column;
background: var(--color-bg-secondary);
}
.sidebar-top-bar {
display: flex;
gap: 0.5rem;
padding: 0.75rem;
}
.btn-new-conv {
flex: 1;
padding: 0.5rem;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.9rem;
transition: box-shadow 0.15s, opacity 0.15s;
}
.btn-new-conv:hover {
opacity: 0.9;
box-shadow: 0 0 14px rgba(129, 140, 248, 0.35);
}
.btn-select-mode {
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text-muted);
font-size: 0.82rem;
padding: 0.4rem 0.6rem;
cursor: pointer;
white-space: nowrap;
}
.btn-select-mode:hover,
.btn-select-mode.active { border-color: var(--color-primary); color: var(--color-primary); }
.bulk-bar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.3rem 0.75rem;
background: color-mix(in srgb, var(--color-primary) 6%, var(--color-bg-secondary));
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
flex-wrap: wrap;
}
.bulk-count {
font-size: 0.75rem;
color: var(--color-text-muted);
flex-shrink: 0;
}
.bulk-link {
background: none;
border: none;
color: var(--color-primary);
font-size: 0.75rem;
cursor: pointer;
padding: 0;
text-decoration: underline;
}
.bulk-delete-btn {
margin-left: auto;
background: none;
border: 1px solid var(--color-danger, #e74c3c);
color: var(--color-danger, #e74c3c);
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
padding: 0.2rem 0.55rem;
cursor: pointer;
}
.bulk-delete-btn:disabled { opacity: 0.5; cursor: default; }
.bulk-delete-btn.confirm {
background: var(--color-danger, #e74c3c);
color: #fff;
}
.conv-checkbox {
flex-shrink: 0;
accent-color: var(--color-primary);
cursor: pointer;
width: 0.9rem;
height: 0.9rem;
}
.conv-item.selected {
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-bg-secondary));
}
.scope-chip-row {
padding: 0.35rem 0.75rem 0;
display: flex;
}
.scope-chip-wrapper {
position: relative;
}
.scope-chip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.72rem;
color: var(--color-text-muted);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: 999px;
padding: 0.2rem 0.65rem;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.scope-chip:hover {
color: var(--color-text);
border-color: var(--color-primary);
}
.scope-dot {
color: var(--color-primary);
font-size: 0.8rem;
}
@keyframes scope-pulse {
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-primary) 40%, transparent); }
100% { box-shadow: 0 0 0 6px transparent; }
}
.scope-chip.pulse {
animation: scope-pulse 0.5s ease-out;
}
.scope-dropdown {
position: absolute;
bottom: calc(100% + 4px);
left: 0;
z-index: 50;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-md, 8px);
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
min-width: 180px;
overflow: hidden;
}
.scope-option {
display: block;
width: 100%;
text-align: left;
padding: 0.5rem 0.85rem;
font-size: 0.82rem;
color: var(--color-text-muted);
background: transparent;
border: none;
cursor: pointer;
transition: background 0.1s, color 0.1s;
}
.scope-option:hover {
background: var(--color-bg-secondary);
color: var(--color-text);
}
.scope-option.active {
color: var(--color-primary);
font-weight: 600;
}
.conv-list {
flex: 1;
overflow-y: auto;
padding: 0 0.5rem 0.5rem;
}
.conv-group-label {
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-muted);
padding: 0.6rem 0.75rem 0.2rem;
position: sticky;
top: 0;
background: var(--color-surface);
z-index: 1;
}
.conv-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.75rem;
border-radius: var(--radius-sm);
cursor: pointer;
margin-bottom: 0.25rem;
border-left: 3px solid transparent;
transition: background 0.15s, border-color 0.15s;
}
.conv-item:hover {
background: rgba(99,102,241,0.05);
border-left: 3px solid var(--color-primary);
}
.conv-item.active {
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
color: var(--color-primary);
border-left: 3px solid var(--color-primary);
}
.conv-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.conv-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.9rem;
}
.conv-date {
font-size: 0.75rem;
color: var(--color-text-muted);
margin-top: 1px;
}
.conv-item.active .conv-date {
color: var(--color-text-muted);
}
.btn-delete-conv {
background: none;
border: none;
color: var(--color-text-muted);
cursor: pointer;
font-size: 1.2rem;
padding: 0 0.25rem;
line-height: 1;
}
.conv-item.active .btn-delete-conv {
color: var(--color-text-muted);
}
.btn-delete-conv:hover {
color: var(--color-danger, #e74c3c);
}
.chat-main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.chat-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
}
.chat-header h2 {
margin: 0;
font-size: 1.1rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.btn-abort {
padding: 0.3rem 0.75rem;
background: none;
color: var(--color-danger, #e74c3c);
border: 1px solid var(--color-danger, #e74c3c);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.85rem;
white-space: nowrap;
font-weight: 600;
}
.btn-abort:hover {
background: var(--color-danger, #e74c3c);
color: #fff;
}
.btn-summarize {
padding: 0.3rem 0.75rem;
background: var(--color-bg-secondary);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.85rem;
white-space: nowrap;
}
.btn-summarize:hover:not(:disabled) {
background: var(--color-primary);
color: #fff;
border-color: var(--color-primary);
}
.btn-summarize:disabled {
opacity: 0.6;
cursor: default;
}
.chat-body {
flex: 1;
display: flex;
min-height: 0;
overflow: hidden;
}
.messages-container {
flex: 1;
overflow-y: auto;
padding: 1rem 1.5rem;
display: flex;
flex-direction: column;
max-width: 1200px;
margin: 0 auto;
width: 100%;
}
.context-sidebar {
width: 220px;
min-width: 180px;
background: var(--color-bg-secondary);
display: flex;
flex-direction: column;
padding: 0.75rem;
gap: 0.35rem;
overflow-y: auto;
}
.context-sidebar-header {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
margin-bottom: 0.25rem;
}
.context-note {
display: flex;
align-items: center;
gap: 0.3rem;
padding: 0.3rem 0.4rem;
border-radius: var(--radius-sm);
background: var(--color-bg-card);
border: 1px solid var(--color-border);
font-size: 0.82rem;
}
.context-note-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-text);
text-decoration: none;
}
.context-note-name:hover {
color: var(--color-primary);
}
.context-sidebar-header-gap {
margin-top: 0.6rem;
}
.context-note-included {
border-color: var(--color-primary);
}
.context-note-auto {
border-color: var(--color-success);
}
.context-note-suggested {
opacity: 0.85;
}
.context-note-score {
font-size: 0.67rem;
font-weight: 600;
padding: 0.05rem 0.22rem;
border-radius: 3px;
flex-shrink: 0;
font-variant-numeric: tabular-nums;
letter-spacing: 0;
}
.score-high { color: var(--color-success); background: color-mix(in srgb, var(--color-success) 12%, transparent); }
.score-medium { color: #c98a00; background: rgba(201, 138, 0, 0.12); }
.score-low { color: var(--color-text-muted); background: var(--color-bg-secondary); }
.context-note-remove {
background: none;
border: none;
cursor: pointer;
color: var(--color-text-muted);
font-size: 1rem;
line-height: 1;
padding: 0 0.1rem;
flex-shrink: 0;
}
.context-note-remove:hover {
color: var(--color-danger, #e74c3c);
}
.context-note-add {
background: none;
border: none;
cursor: pointer;
color: var(--color-primary);
font-size: 1.1rem;
font-weight: 700;
line-height: 1;
padding: 0 0.1rem;
flex-shrink: 0;
}
.context-note-add:hover {
opacity: 0.75;
}
.messages-inner {
margin-top: auto;
}
/* Streaming bubble — matches ChatMessage assistant style */
.chat-message {
display: flex;
margin-bottom: 0.75rem;
}
.role-assistant {
justify-content: flex-start;
}
.streaming-bubble {
max-width: 80%;
padding: 0.75rem 1rem;
border-radius: 18px;
border-bottom-left-radius: 4px;
background: var(--color-bg-card);
border-left: 2px solid var(--color-primary);
box-shadow: var(--color-bubble-asst-shadow);
}
.streaming-bubble .message-header {
display: flex;
align-items: center;
margin-bottom: 0.25rem;
}
.streaming-bubble .role-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-primary);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.streaming-bubble .message-content {
font-size: 0.95rem;
line-height: 1.55;
word-break: break-word;
}
.streaming-tool-calls {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
margin-bottom: 0.4rem;
}
.streaming-status-line {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.8rem;
color: var(--color-text-muted);
font-style: italic;
margin-bottom: 0.3rem;
}
.thinking-block {
margin-bottom: 0.5rem;
border-left: 2px solid rgba(129, 140, 248, 0.35);
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
overflow: hidden;
}
.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;
user-select: none;
list-style: none;
display: flex;
align-items: center;
gap: 0.3rem;
background: var(--color-bg-secondary);
}
.thinking-summary::-webkit-details-marker { display: none; }
.thinking-summary::before {
content: "▶";
font-size: 0.6rem;
transition: transform 0.15s;
}
details[open] .thinking-summary::before {
transform: rotate(90deg);
}
.thinking-text {
margin: 0;
padding: 0.5rem;
font-size: 0.8rem;
line-height: 1.5;
color: var(--color-text-secondary);
white-space: pre-wrap;
word-break: break-word;
max-height: 300px;
overflow-y: auto;
background: var(--color-bg-secondary);
}
.streaming-status-dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--color-primary);
animation: blink 1s infinite;
flex-shrink: 0;
}
.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;
}
@keyframes blink {
0%, 100% { opacity: 0.3; }
50% { opacity: 1; }
}
/* Input wrapper */
.input-wrapper {
max-width: 1200px;
margin: 0 auto 2rem;
padding: 0 1rem;
width: 100%;
box-sizing: border-box;
}
/* Floating input bar */
.input-area {
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);
}
/* Note picker */
.note-picker-wrapper {
position: relative;
}
.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;
}
.note-picker-dropdown {
position: absolute;
bottom: calc(100% + 8px);
left: 0;
width: 280px;
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: 10;
overflow: hidden;
}
.note-picker-search {
width: 100%;
padding: 0.5rem 0.75rem;
border: none;
border-bottom: 1px solid var(--color-border);
background: transparent;
color: var(--color-text);
font-size: 0.9rem;
outline: none;
font-family: inherit;
box-sizing: border-box;
}
.note-picker-results {
max-height: 200px;
overflow-y: auto;
}
.note-picker-item {
padding: 0.5rem 0.75rem;
cursor: pointer;
font-size: 0.9rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.note-picker-item:hover {
background: var(--color-bg-secondary);
}
.note-picker-empty {
padding: 0.5rem 0.75rem;
color: var(--color-text-muted);
font-size: 0.85rem;
}
/* Research modal */
.research-wrapper {
position: relative;
}
.research-modal {
position: absolute;
bottom: calc(100% + 8px);
left: 0;
width: 280px;
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: 10;
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.research-modal-header {
font-size: 0.8rem;
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.research-topic-input {
width: 100%;
padding: 0.4rem 0.6rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg-secondary);
color: var(--color-text);
font-size: 0.9rem;
outline: none;
font-family: inherit;
box-sizing: border-box;
}
.research-topic-input:focus {
border-color: var(--color-primary);
}
.research-modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.4rem;
}
.btn-research-cancel {
padding: 0.3rem 0.65rem;
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text-muted);
font-size: 0.85rem;
cursor: pointer;
font-family: inherit;
}
.btn-research-cancel:hover {
background: var(--color-bg-secondary);
}
.btn-research-go {
padding: 0.3rem 0.75rem;
background: var(--color-primary);
border: none;
border-radius: var(--radius-sm);
color: #fff;
font-size: 0.85rem;
cursor: pointer;
font-family: inherit;
}
.btn-research-go:disabled {
opacity: 0.4;
cursor: default;
}
.btn-research-go:not(:disabled):hover {
opacity: 0.9;
}
.input-area 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: 150px;
overflow-y: auto;
}
.input-area textarea::placeholder {
color: var(--color-input-bar-placeholder);
}
.input-area textarea:disabled {
opacity: 0.5;
}
.btn-mic-ptt {
touch-action: none;
user-select: none;
transition: color 0.15s, opacity 0.15s;
}
.btn-mic-ptt.mic-recording {
color: #ef4444 !important;
opacity: 1 !important;
animation: mic-pulse 0.8s ease-in-out infinite;
}
.btn-mic-ptt.mic-transcribing {
color: var(--color-primary) !important;
opacity: 0.7 !important;
}
@keyframes mic-pulse {
0%, 100% { opacity: 0.7; }
50% { opacity: 1; }
}
.btn-send {
width: 34px;
min-width: 34px;
height: 34px;
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: 1.1rem;
flex-shrink: 0;
transition: box-shadow 0.15s;
}
.btn-send:not(:disabled):hover {
box-shadow: 0 0 14px rgba(129, 140, 248, 0.5);
}
.btn-send:disabled {
opacity: 0.35;
cursor: default;
}
.btn-stop {
width: 34px;
min-width: 34px;
height: 34px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-danger, #e74c3c);
color: #fff;
border: none;
border-radius: 50%;
cursor: pointer;
flex-shrink: 0;
}
.btn-stop:hover {
opacity: 0.85;
}
.no-conversation {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
color: var(--color-text-muted);
}
.empty-msg {
color: var(--color-text-muted);
font-size: 0.9rem;
text-align: center;
padding: 1rem;
}
.btn-sidebar-toggle {
background: none;
border: none;
cursor: pointer;
color: var(--color-text);
padding: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
}
.no-conv-toggle {
position: absolute;
top: 0.75rem;
left: 0.75rem;
}
.no-conversation {
position: relative;
}
.sidebar-overlay {
display: none;
}
@media (max-width: 768px) {
.chat-sidebar {
position: fixed;
top: 49px;
left: 0;
bottom: 0;
z-index: 100;
transform: translateX(-100%);
transition: transform 0.25s ease;
width: 260px;
}
.chat-sidebar.open {
transform: translateX(0);
}
.sidebar-overlay {
display: block;
position: fixed;
inset: 0;
top: 49px;
background: var(--color-overlay);
z-index: 99;
}
.context-sidebar {
display: none;
}
.messages-container {
padding: 0.75rem;
}
.input-wrapper {
padding: 0 0.5rem;
margin-bottom: 0.5rem;
}
}
.chat-message.role-user {
justify-content: flex-end;
}
.queued-bubble {
max-width: 80%;
padding: 0.75rem 1rem;
border-radius: 18px;
border-bottom-right-radius: 4px;
background: linear-gradient(135deg, #6366f1, #4f46e5);
color: #fff;
opacity: 0.45;
font-size: 0.95rem;
line-height: 1.55;
word-break: break-word;
}
.queued-badge {
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: rgba(255, 255, 255, 0.75);
margin-bottom: 0.2rem;
}
.queued-clear-row {
display: flex;
justify-content: flex-end;
padding-right: 0.5rem;
}
.queued-clear-btn {
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm, 6px);
cursor: pointer;
color: var(--color-text-muted);
font-size: 0.78rem;
padding: 0.2rem 0.6rem;
font-family: inherit;
}
.queued-clear-btn:hover {
color: var(--color-danger, #e74c3c);
border-color: var(--color-danger, #e74c3c);
}
</style>