23a7ed7822
Add --page-max-width (1200px), --page-padding-x (1rem), and --sidebar-width (260px) to theme.css so all views share a single source of truth. - HomeView: 1100px → var(--page-max-width) (aligns with all other views) - NotesListView, TasksListView, ProjectListView, ProjectView, CalendarView: var(--page-max-width) - ChatView: sidebar + context sidebar → var(--sidebar-width); inner message/input column max-widths → var(--page-max-width) - KnowledgeView: filter panel 180px → var(--sidebar-width); minichat left offset updated Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1985 lines
57 KiB
Vue
1985 lines
57 KiB
Vue
<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, synthesiseSpeech } from "@/api/client";
|
||
import { renderMarkdown } from "@/utils/markdown";
|
||
import { useVoiceRecorder } from "@/composables/useVoiceRecorder";
|
||
import { useVoiceAudio, setVoiceVolume } from "@/composables/useVoiceAudio";
|
||
import { useListenMode } from "@/composables/useListenMode";
|
||
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 audio = useVoiceAudio();
|
||
const voiceEnabled = computed(() => settingsStore.voiceSttReady);
|
||
const voiceTtsEnabled = computed(() => settingsStore.voiceTtsReady);
|
||
const listenMode = useListenMode();
|
||
const synthesising = ref(false);
|
||
const showVolumeSlider = ref(false);
|
||
|
||
async function speakLastAssistantMessage() {
|
||
const messages = store.currentConversation?.messages ?? [];
|
||
const last = [...messages].reverse().find((m) => m.role === "assistant");
|
||
if (!last?.content) return;
|
||
const plain = last.content
|
||
.replace(/```[\s\S]*?```/g, "")
|
||
.replace(/`[^`]+`/g, (m: string) => m.slice(1, -1))
|
||
.replace(/#{1,6}\s+/g, "")
|
||
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
||
.replace(/\*([^*]+)\*/g, "$1")
|
||
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
||
.replace(/^\s*[-*+]\s+/gm, "")
|
||
.replace(/\n{2,}/g, " ")
|
||
.trim();
|
||
if (!plain) return;
|
||
synthesising.value = true;
|
||
try {
|
||
const blob = await synthesiseSpeech(plain);
|
||
await audio.play(blob);
|
||
} catch { /* TTS failure non-critical */ }
|
||
finally { synthesising.value = false; }
|
||
}
|
||
|
||
watch(() => store.streaming, async (streaming) => {
|
||
if (!streaming && listenMode.value && voiceTtsEnabled.value) {
|
||
await new Promise((r) => setTimeout(r, 200));
|
||
await speakLastAssistantMessage();
|
||
}
|
||
});
|
||
|
||
async function startPtt() {
|
||
if (!voiceEnabled.value || recorder.recording.value) return;
|
||
audio.stop();
|
||
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.45–0.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"
|
||
>
|
||
×
|
||
</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">×</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">×</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>
|
||
|
||
<!-- Listen mode + volume (TTS) -->
|
||
<template v-if="voiceTtsEnabled">
|
||
<div class="volume-wrapper">
|
||
<button
|
||
class="btn-attach btn-listen"
|
||
:class="{ 'btn-listen--active': listenMode, 'btn-listen--busy': synthesising || audio.playing.value }"
|
||
@click="listenMode = !listenMode; if (listenMode) speakLastAssistantMessage()"
|
||
:title="listenMode ? 'Stop auto-read' : 'Read responses aloud'"
|
||
aria-label="Toggle listen mode"
|
||
>
|
||
<svg v-if="!synthesising && !audio.playing.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-attach btn-volume"
|
||
@click="showVolumeSlider = !showVolumeSlider"
|
||
:title="`Volume: ${Math.round(audio.volume.value * 100)}%`"
|
||
aria-label="Volume"
|
||
>
|
||
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor">
|
||
<path v-if="audio.volume.value === 0" d="M16.5 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 3z"/>
|
||
<path v-else 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"
|
||
aria-label="Volume"
|
||
/>
|
||
<span class="volume-pct">{{ Math.round(audio.volume.value * 100) }}%</span>
|
||
</div>
|
||
</div>
|
||
<button
|
||
v-if="synthesising || audio.playing.value"
|
||
class="btn-attach"
|
||
@click="audio.stop(); synthesising = false"
|
||
title="Stop playback"
|
||
>
|
||
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor"><rect width="14" height="14" rx="2"/></svg>
|
||
</button>
|
||
</template>
|
||
|
||
<!-- 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"
|
||
>
|
||
↑
|
||
</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: var(--sidebar-width);
|
||
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: var(--page-max-width);
|
||
margin: 0 auto;
|
||
width: 100%;
|
||
}
|
||
|
||
.context-sidebar {
|
||
width: var(--sidebar-width);
|
||
min-width: 200px;
|
||
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: var(--page-max-width);
|
||
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; }
|
||
}
|
||
|
||
/* Listen mode + volume controls */
|
||
.volume-wrapper {
|
||
position: relative;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 2px;
|
||
}
|
||
.btn-listen--active {
|
||
color: var(--color-primary) !important;
|
||
opacity: 1 !important;
|
||
}
|
||
.btn-listen--busy {
|
||
color: #a78bfa !important;
|
||
opacity: 1 !important;
|
||
}
|
||
.volume-popup {
|
||
position: absolute;
|
||
bottom: calc(100% + 8px);
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
background: var(--color-surface, #1e1e2e);
|
||
border: 1px solid var(--color-border, rgba(255,255,255,0.08));
|
||
border-radius: 10px;
|
||
padding: 8px 12px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||
white-space: nowrap;
|
||
z-index: 50;
|
||
}
|
||
.volume-range {
|
||
width: 96px;
|
||
accent-color: var(--color-primary, #6366f1);
|
||
cursor: pointer;
|
||
}
|
||
.volume-pct {
|
||
font-size: 0.72rem;
|
||
color: var(--color-muted, rgba(255,255,255,0.4));
|
||
min-width: 28px;
|
||
text-align: right;
|
||
}
|
||
|
||
.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: var(--sidebar-width);
|
||
}
|
||
.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>
|