Files
FabledScribe/frontend/src/views/ChatView.vue
T
bvandeusen 3ef0e9f2c5 Delete empty untitled chat on navigate-away from ChatView
Previously the empty-conversation cleanup only fired when switching
between chats (convId watcher). Navigating to /notes, /tasks, etc.
left orphaned untitled empty conversations. onUnmounted now runs the
same check, deleting the current conversation if message_count === 0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 18:02:57 -04:00

1678 lines
46 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 } from "@/api/client";
import { renderMarkdown } from "@/utils/markdown";
import ChatMessage from "@/components/ChatMessage.vue";
import ToolCallCard from "@/components/ToolCallCard.vue";
import ToolConfirmCard from "@/components/ToolConfirmCard.vue";
import type { Note } from "@/types/note";
import ProjectSelector from "@/components/ProjectSelector.vue";
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);
// 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[]>([]);
// Project scope for RAG — when set, semantic & keyword search is restricted to this project
const ragProjectId = ref<number | null>(null);
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 store.fetchConversations();
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,
ragProjectId.value,
);
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>
<!-- RAG project scope -->
<div class="rag-scope-section">
<label class="rag-scope-label">Scope notes to project</label>
<ProjectSelector v-model="ragProjectId" />
<p v-if="ragProjectId" class="rag-scope-hint">RAG search restricted to this project</p>
</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">
<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>
<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));
}
.rag-scope-section {
padding: 0.5rem 0.75rem 0.25rem;
border-bottom: 1px solid var(--color-border);
}
.rag-scope-label {
display: block;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
margin-bottom: 0.3rem;
}
.rag-scope-hint {
margin: 0.3rem 0 0;
font-size: 0.7rem;
color: var(--color-primary);
font-style: italic;
}
.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;
}
.conv-item:hover {
background: var(--color-bg-card);
}
.conv-item.active {
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
color: 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-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>