From fb18d2c41d1b17a65009595b7a591cd5557b8f64 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 11 Feb 2026 06:49:12 -0500 Subject: [PATCH] Add note context visibility in chat and standardize UI design tokens Improve chat context: build_context() now returns metadata about auto-found notes, emitted as an SSE event so the frontend can display context pills showing which notes influenced the response. Users can promote notes for deeper context (+) or exclude irrelevant ones (x). A note picker lets users manually attach notes. Multi-word search uses per-term AND matching, and auto-search iterates keywords individually for broader OR-style coverage. Standardize styling: introduce CSS design tokens (--radius-sm/md/lg/pill, --color-success/warning/overlay, --focus-ring) and migrate all components to use them. Fix header alignment to full-width, add active nav link state, replace hardcoded colors with CSS variables, and normalize button padding. Co-Authored-By: Claude Opus 4.6 --- frontend/src/assets/theme.css | 25 ++ frontend/src/components/AppHeader.vue | 29 +- frontend/src/components/ChatMessage.vue | 56 ++- frontend/src/components/ChatPanel.vue | 425 ++++++++++++++++--- frontend/src/components/MarkdownToolbar.vue | 2 +- frontend/src/components/NoteCard.vue | 2 +- frontend/src/components/PaginationBar.vue | 2 +- frontend/src/components/SearchBar.vue | 2 +- frontend/src/components/TaskCard.vue | 2 +- frontend/src/stores/chat.ts | 19 +- frontend/src/types/chat.ts | 6 + frontend/src/views/ChatView.vue | 430 +++++++++++++++++--- frontend/src/views/HomeView.vue | 6 +- frontend/src/views/NoteEditorView.vue | 26 +- frontend/src/views/NoteViewerView.vue | 4 +- frontend/src/views/NotesListView.vue | 12 +- frontend/src/views/SettingsView.vue | 34 +- frontend/src/views/TaskEditorView.vue | 28 +- frontend/src/views/TaskViewerView.vue | 4 +- frontend/src/views/TasksListView.vue | 12 +- src/fabledassistant/routes/chat.py | 9 +- src/fabledassistant/routes/settings.py | 26 ++ src/fabledassistant/services/chat.py | 11 +- src/fabledassistant/services/llm.py | 63 ++- src/fabledassistant/services/notes.py | 12 +- summary.md | 60 ++- 26 files changed, 1070 insertions(+), 237 deletions(-) diff --git a/frontend/src/assets/theme.css b/frontend/src/assets/theme.css index 3748902..ba2b8a1 100644 --- a/frontend/src/assets/theme.css +++ b/frontend/src/assets/theme.css @@ -32,6 +32,17 @@ --color-code-bg: #f6f8fa; --color-code-inline-bg: #eff1f3; --color-table-stripe: #f9f9f9; + --color-success: #22c55e; + --color-warning: #eab308; + --color-input-bar-bg: #f0f0f0; + --color-input-bar-text: #1a1a1a; + --color-input-bar-placeholder: rgba(0, 0, 0, 0.4); + --color-overlay: rgba(0, 0, 0, 0.45); + --radius-sm: 6px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-pill: 9999px; + --focus-ring: 0 0 0 2px color-mix(in srgb, var(--color-primary) 40%, transparent); } [data-theme="dark"] { @@ -68,6 +79,12 @@ --color-code-bg: #161b22; --color-code-inline-bg: #2a3040; --color-table-stripe: #1a2030; + --color-success: #4ade80; + --color-warning: #facc15; + --color-input-bar-bg: #1c1c1e; + --color-input-bar-text: #ffffff; + --color-input-bar-placeholder: rgba(255, 255, 255, 0.4); + --color-overlay: rgba(0, 0, 0, 0.6); } *, @@ -85,3 +102,11 @@ body { line-height: 1.5; transition: background-color 0.2s, color 0.2s; } + +input:focus-visible, +textarea:focus-visible, +select:focus-visible, +button:focus-visible { + outline: none; + box-shadow: var(--focus-ring); +} diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue index a8dd257..e2a7c9b 100644 --- a/frontend/src/components/AppHeader.vue +++ b/frontend/src/components/AppHeader.vue @@ -54,9 +54,7 @@ const statusClass = computed(() => { border-bottom: 1px solid var(--color-border); } .nav { - max-width: 960px; - margin: 0 auto; - padding: 0.5rem 1rem; + padding: 0.5rem 1.25rem; display: flex; align-items: center; justify-content: space-between; @@ -70,19 +68,29 @@ const statusClass = computed(() => { .nav-links { display: flex; align-items: center; - gap: 1rem; + gap: 0.25rem; } .nav-link { color: var(--color-text-secondary); text-decoration: none; - font-size: 0.95rem; + font-size: 0.9rem; + padding: 0.3rem 0.6rem; + border-radius: var(--radius-sm); + transition: background 0.15s, color 0.15s; } .nav-link:hover { color: var(--color-primary); + background: var(--color-bg-card); +} +.nav-link.router-link-active { + color: var(--color-primary); + background: var(--color-bg-card); + font-weight: 600; } .status-indicator { display: flex; align-items: center; + margin-left: 0.5rem; } .status-dot { width: 8px; @@ -91,14 +99,14 @@ const statusClass = computed(() => { flex-shrink: 0; } .status-green .status-dot { - background: #22c55e; + background: var(--color-success); } .status-yellow .status-dot { - background: #eab308; + background: var(--color-warning); animation: pulse-dot 2s infinite; } .status-red .status-dot { - background: #ef4444; + background: var(--color-danger); } @keyframes pulse-dot { 0%, 100% { opacity: 1; } @@ -107,12 +115,13 @@ const statusClass = computed(() => { .btn-chat-panel { background: none; border: 1px solid var(--color-border); - border-radius: 4px; + border-radius: var(--radius-sm); padding: 0.25rem 0.5rem; cursor: pointer; font-size: 1rem; color: var(--color-text); line-height: 1; + margin-left: 0.25rem; } .btn-chat-panel:hover { background: var(--color-bg-card); @@ -120,7 +129,7 @@ const statusClass = computed(() => { .theme-toggle { background: none; border: 1px solid var(--color-border); - border-radius: 4px; + border-radius: var(--radius-sm); padding: 0.25rem 0.5rem; cursor: pointer; font-size: 1.1rem; diff --git a/frontend/src/components/ChatMessage.vue b/frontend/src/components/ChatMessage.vue index 069bf1e..76ce042 100644 --- a/frontend/src/components/ChatMessage.vue +++ b/frontend/src/components/ChatMessage.vue @@ -17,6 +17,12 @@ const emit = defineEmits<{ const rendered = computed(() => renderMarkdown(props.message.content)); +const formattedTime = computed(() => { + if (!props.message.created_at) return ""; + const date = new Date(props.message.created_at); + return date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }); +}); + const roleLabel = computed(() => { switch (props.message.role) { case "user": @@ -31,24 +37,27 @@ const roleLabel = computed(() => { @@ -65,8 +74,11 @@ const roleLabel = computed(() => { justify-content: flex-start; } -.message-bubble { +.message-group { max-width: 80%; +} + +.message-bubble { padding: 0.75rem 1rem; border-radius: 16px; } @@ -136,7 +148,7 @@ const roleLabel = computed(() => { padding: 0.1rem 0.4rem; background: transparent; border: 1px solid var(--color-border); - border-radius: 4px; + border-radius: var(--radius-sm); color: var(--color-text-secondary); cursor: pointer; } @@ -159,4 +171,12 @@ const roleLabel = computed(() => { .context-badge a:hover { text-decoration: underline; } + +.message-timestamp { + display: block; + font-size: 0.7rem; + color: var(--color-text-muted); + margin-top: 0.15rem; + padding: 0 0.5rem; +} diff --git a/frontend/src/components/ChatPanel.vue b/frontend/src/components/ChatPanel.vue index 4da27bc..8a15bb1 100644 --- a/frontend/src/components/ChatPanel.vue +++ b/frontend/src/components/ChatPanel.vue @@ -2,8 +2,10 @@ import { ref, computed, watch, nextTick } from "vue"; 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 type { Note } from "@/types/note"; const props = defineProps<{ contextNoteId?: number | null; @@ -17,6 +19,18 @@ const store = useChatStore(); const settingsStore = useSettingsStore(); const messageInput = ref(""); const messagesEl = ref(null); +const inputEl = ref(null); + +// Note picker state +const attachedNote = ref<{ id: number; title: string } | null>(null); +const showNotePicker = ref(false); +const noteSearchQuery = ref(""); +const noteSearchResults = ref<{ id: number; title: string }[]>([]); +const noteSearchLoading = ref(false); +let noteSearchTimer: ReturnType | null = null; + +// Exclude tracking +const excludedNoteIds = ref>(new Set()); const streamingRendered = computed(() => { if (!store.streamingContent) return ""; @@ -46,9 +60,16 @@ async function sendMessage() { await store.fetchConversation(conv.id); } + const contextNoteId = attachedNote.value?.id ?? props.contextNoteId; + attachedNote.value = null; messageInput.value = ""; + resetTextareaHeight(); scrollToBottom(); - await store.sendMessage(content, props.contextNoteId); + await store.sendMessage( + content, + contextNoteId, + excludedNoteIds.value.size ? [...excludedNoteIds.value] : undefined, + ); scrollToBottom(); } @@ -63,12 +84,77 @@ async function handleSaveAsNote(messageId: number) { } } +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(); } } + +// Note picker +function toggleNotePicker() { + showNotePicker.value = !showNotePicker.value; + if (showNotePicker.value) { + noteSearchQuery.value = ""; + noteSearchResults.value = []; + nextTick(() => { + const el = document.querySelector(".panel-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 }) { + attachedNote.value = note; + showNotePicker.value = false; +} + +function removeAttachedNote() { + attachedNote.value = null; +} + +function promoteAutoNote(note: { id: number; title: string }) { + attachedNote.value = note; +} + +function excludeAutoNote(noteId: number) { + excludedNoteIds.value = new Set([...excludedNoteIds.value, noteId]); +}