From a40ae9437dacf34409197a008240808e4cf2cb5c Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Sun, 1 Mar 2026 15:42:31 -0700 Subject: [PATCH] Add chat widget to docs site: floating panel for Q&A + live celestial queries Floating chat panel injected via Starlight Head override: - ChatWidget.astro wrapper with astro:after-swap for View Transitions - ~900-line TypeScript widget: SSE streaming, conversation persistence (localStorage), markdown rendering (marked + DOMPurify), suggestion pills, two-click delete, conversation history, rAF-throttled streaming - ~680-line CSS: dark/light themes (amber/slate palette), full-screen mobile layout with FAB/Ask button overlap fix, reduced-motion support - Three domain-specific suggestions: "Where is Jupiter right now?", "How do I predict satellite passes?", "What functions handle rise/set prediction?" - FAB uses orbit SVG icon (3 rotated ellipses + center circle) - Wired to /api/chat/stream endpoint (search backend) --- docs/astro.config.mjs | 1 + docs/package-lock.json | 40 +- docs/package.json | 3 + docs/src/components/ChatWidget.astro | 10 + docs/src/components/Head.astro | 2 + docs/src/components/chat-widget.ts | 1074 ++++++++++++++++++++++++++ docs/src/styles/chat-widget.css | 889 +++++++++++++++++++++ 7 files changed, 2011 insertions(+), 8 deletions(-) create mode 100644 docs/src/components/ChatWidget.astro create mode 100644 docs/src/components/chat-widget.ts create mode 100644 docs/src/styles/chat-widget.css diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 2d22c13..be8771b 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -33,6 +33,7 @@ export default defineConfig({ customCss: [ "./src/styles/custom.css", "./src/styles/katex-fixes.css", + "./src/styles/chat-widget.css", "katex/dist/katex.min.css", ], head: [ diff --git a/docs/package-lock.json b/docs/package-lock.json index 2f4b673..d787e9c 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -17,7 +17,9 @@ "astro-mermaid": "^1.3.1", "astro-opengraph-images": "^1.14.3", "astro-seo-meta": "^5.2.0", + "dompurify": "^3.2.0", "katex": "^0.16.28", + "marked": "^15.0.0", "react": "^19.2.4", "rehype-katex": "^7.0.1", "remark-math": "^6.0.0", @@ -25,6 +27,7 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", + "@types/dompurify": "^3.2.0", "tailwindcss": "^4.0.0", "typescript": "^5.7.0" } @@ -3072,6 +3075,17 @@ "@types/ms": "*" } }, + "node_modules/@types/dompurify": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.2.0.tgz", + "integrity": "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==", + "deprecated": "This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.", + "dev": true, + "license": "MIT", + "dependencies": { + "dompurify": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -3168,8 +3182,7 @@ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/@types/unist": { "version": "3.0.3", @@ -5902,7 +5915,6 @@ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", "license": "(MPL-2.0 OR Apache-2.0)", - "peer": true, "optionalDependencies": { "@types/trusted-types": "^2.0.7" } @@ -7771,16 +7783,15 @@ } }, "node_modules/marked": { - "version": "16.4.2", - "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", - "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", + "version": "15.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", + "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, "engines": { - "node": ">= 20" + "node": ">= 18" } }, "node_modules/mdast-util-definitions": { @@ -8170,6 +8181,19 @@ "uuid": "^11.1.0" } }, + "node_modules/mermaid/node_modules/marked": { + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", + "license": "MIT", + "peer": true, + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/micromark": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", diff --git a/docs/package.json b/docs/package.json index 418bef1..b76aac0 100644 --- a/docs/package.json +++ b/docs/package.json @@ -22,10 +22,13 @@ "react": "^19.2.4", "rehype-katex": "^7.0.1", "remark-math": "^6.0.0", + "dompurify": "^3.2.0", + "marked": "^15.0.0", "sharp": "^0.33.0" }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", + "@types/dompurify": "^3.2.0", "tailwindcss": "^4.0.0", "typescript": "^5.7.0" } diff --git a/docs/src/components/ChatWidget.astro b/docs/src/components/ChatWidget.astro new file mode 100644 index 0000000..9554a1d --- /dev/null +++ b/docs/src/components/ChatWidget.astro @@ -0,0 +1,10 @@ +--- +// Chat widget — floating panel for docs Q&A + live celestial queries +// Appends to document.body to escape Starlight stacking contexts +--- + + diff --git a/docs/src/components/Head.astro b/docs/src/components/Head.astro index de76a82..c444d66 100644 --- a/docs/src/components/Head.astro +++ b/docs/src/components/Head.astro @@ -9,6 +9,7 @@ */ import Default from "@astrojs/starlight/components/Head.astro"; import { getImagePath } from "astro-opengraph-images"; +import ChatWidget from "./ChatWidget.astro"; const route = Astro.locals.starlightRoute; const title = route?.entry?.data?.title ?? "pg_orrery"; @@ -23,3 +24,4 @@ const ogImageUrl = getImagePath({ url: Astro.url, site: Astro.site }); + diff --git a/docs/src/components/chat-widget.ts b/docs/src/components/chat-widget.ts new file mode 100644 index 0000000..df9578b --- /dev/null +++ b/docs/src/components/chat-widget.ts @@ -0,0 +1,1074 @@ +/** + * Chat widget for pg_orrery documentation. + * Floating panel with conversation history management. + * Powered by the RAG pipeline via POST /api/chat + live pg_orrery SQL. + */ + +import { Marked } from 'marked'; +import DOMPurify from 'dompurify'; + +interface ChatSource { + title: string; + slug: string; + url: string; + score: number; +} + +interface ChatMessage { + role: 'user' | 'assistant'; + text: string; + sources?: ChatSource[]; +} + +interface ConversationMeta { + id: string; + title: string; + createdAt: number; + updatedAt: number; + messageCount: number; +} + +// ---- Storage keys and limits ---- + +const STORAGE_KEY_INDEX = 'orrery-chat-conversations'; +const STORAGE_KEY_ACTIVE = 'orrery-chat-active'; +const STORAGE_KEY_PREFIX = 'orrery-chat-conv-'; +const MAX_CONVERSATIONS = 20; +const MAX_MESSAGES = 50; +const TITLE_MAX_LENGTH = 60; + +const SUGGESTIONS_DEFAULT = [ + 'Where is Jupiter right now?', + 'How do I predict satellite passes?', + 'What functions handle rise/set prediction?', +]; + +function getSuggestions(): string[] { + return SUGGESTIONS_DEFAULT; +} + +// ---- Utility functions ---- + +function escapeHtml(text: string): string { + const el = document.createElement('span'); + el.textContent = text; + return el.innerHTML; +} + +// ---- Markdown rendering (scoped instance — no global mutation) ---- + +const chatMarkdown = new Marked({ breaks: true, gfm: true, async: false }); + +DOMPurify.addHook('afterSanitizeAttributes', (node) => { + if (node.tagName === 'A' && node.hasAttribute('href')) { + const href = node.getAttribute('href'); + if (href && /^https?:\/\//i.test(href)) { + node.setAttribute('target', '_blank'); + node.setAttribute('rel', 'noopener noreferrer'); + } + } + if (node.tagName === 'INPUT') { + if (node.getAttribute('type') !== 'checkbox') { + node.remove(); + } + } +}); + +function renderMarkdownSafe(text: string): string { + try { + const rawHtml = chatMarkdown.parse(text) as string; + return DOMPurify.sanitize(rawHtml, { + ADD_ATTR: ['target'], + ALLOWED_TAGS: [ + 'p', 'br', 'strong', 'em', 'del', 's', + 'code', 'pre', + 'ul', 'ol', 'li', + 'blockquote', + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'a', 'table', 'thead', 'tbody', 'tr', 'th', 'td', + 'hr', 'input', + ], + ALLOWED_ATTR: ['href', 'target', 'rel', 'title', 'type', 'checked', 'disabled'], + }); + } catch { + return escapeHtml(text).replace(/\n/g, '
'); + } +} + +// ---- Storage helpers ---- + +function loadIndex(): ConversationMeta[] { + try { + const raw = localStorage.getItem(STORAGE_KEY_INDEX); + if (!raw) return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function saveIndex(index: ConversationMeta[]): void { + try { + localStorage.setItem(STORAGE_KEY_INDEX, JSON.stringify(index)); + } catch { + // Storage full or unavailable + } +} + +function loadConversation(id: string): ChatMessage[] { + try { + const raw = localStorage.getItem(STORAGE_KEY_PREFIX + id); + if (!raw) return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.slice(-MAX_MESSAGES) : []; + } catch { + return []; + } +} + +function saveConversation(id: string, msgs: ChatMessage[]): void { + try { + localStorage.setItem( + STORAGE_KEY_PREFIX + id, + JSON.stringify(msgs.slice(-MAX_MESSAGES)), + ); + } catch { + // Storage full or unavailable + } +} + +function removeConversationData(id: string): void { + try { + localStorage.removeItem(STORAGE_KEY_PREFIX + id); + } catch { + // Silently continue + } +} + +function getActiveId(): string | null { + try { + return localStorage.getItem(STORAGE_KEY_ACTIVE); + } catch { + return null; + } +} + +function setActiveId(id: string): void { + try { + localStorage.setItem(STORAGE_KEY_ACTIVE, id); + } catch { + // Silently continue + } +} + +// ---- ID generation and title derivation ---- + +function generateId(): string { + return Date.now().toString(36) + Math.random().toString(36).slice(2, 6); +} + +function deriveTitle(msgs: ChatMessage[]): string { + const firstUser = msgs.find((m) => m.role === 'user'); + if (!firstUser) return 'New conversation'; + const text = firstUser.text.trim(); + if (text.length <= TITLE_MAX_LENGTH) return text; + const truncated = text.slice(0, TITLE_MAX_LENGTH); + const lastSpace = truncated.lastIndexOf(' '); + return (lastSpace > 20 ? truncated.slice(0, lastSpace) : truncated) + '\u2026'; +} + +function formatRelativeDate(timestamp: number): string { + const diff = Date.now() - timestamp; + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(diff / 3600000); + const days = Math.floor(diff / 86400000); + + if (minutes < 1) return 'just now'; + if (minutes < 60) return `${minutes}m ago`; + if (hours < 24) return `${hours}h ago`; + if (days < 7) return `${days}d ago`; + + return new Date(timestamp).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }); +} + +// ---- Lucide SVG icon paths ---- + +const ICON_MESSAGE_PLUS = + '' + + ''; + +const ICON_HISTORY = + '' + + ''; + +const ICON_CHEVRON_LEFT = ''; + +const ICON_TRASH = + '' + + '' + + ''; + +function svgIcon(paths: string): string { + return ``; +} + +// ---- Main widget ---- + +let cleanupPreviousInit: (() => void) | null = null; + +export function initChatWidget(): void { + if (document.getElementById('orrery-chat-fab')) return; + + if (cleanupPreviousInit) { + cleanupPreviousInit(); + cleanupPreviousInit = null; + } + + let activeConversationId: string | null = getActiveId(); + let messages: ChatMessage[] = []; + let abortController: AbortController | null = null; + let panelOpen = false; + let viewMode: 'chat' | 'history' = 'chat'; + let pendingDeleteId: string | null = null; + let pendingDeleteTimer: number | null = null; + + let streamingText = ''; + let streamingSources: ChatSource[] = []; + let conversationSwitchInProgress = false; + + let rafPending = false; + let rafBubble: HTMLElement | null = null; + let rafText = ''; + let rafHandle: number | null = null; + + function scheduleStreamRender(bubble: HTMLElement, text: string): void { + rafBubble = bubble; + rafText = text; + if (!rafPending) { + rafPending = true; + rafHandle = requestAnimationFrame(() => { + rafPending = false; + rafHandle = null; + if (rafBubble) { + rafBubble.innerHTML = renderMarkdownSafe(rafText); + messagesEl.scrollTop = messagesEl.scrollHeight; + } + }); + } + } + + function cancelStreamRender(): void { + if (rafHandle !== null) { + cancelAnimationFrame(rafHandle); + rafHandle = null; + } + rafPending = false; + rafBubble = null; + rafText = ''; + } + + // ---- Conversation lifecycle ---- + + function ensureActiveConversation(): void { + const index = loadIndex(); + if (activeConversationId && index.some((c) => c.id === activeConversationId)) return; + + if (index.length > 0) { + activeConversationId = index[0].id; + } else { + createNewConversation(); + return; + } + setActiveId(activeConversationId!); + } + + function createNewConversation(): string { + const id = generateId(); + const now = Date.now(); + const index = loadIndex(); + index.unshift({ + id, + title: 'New conversation', + createdAt: now, + updatedAt: now, + messageCount: 0, + }); + + while (index.length > MAX_CONVERSATIONS) { + const removed = index.pop()!; + removeConversationData(removed.id); + } + + saveIndex(index); + saveConversation(id, []); + activeConversationId = id; + setActiveId(id); + return id; + } + + function saveHistory(): void { + if (!activeConversationId) return; + saveConversation(activeConversationId, messages); + + const index = loadIndex(); + const entry = index.find((c) => c.id === activeConversationId); + if (entry) { + entry.updatedAt = Date.now(); + entry.messageCount = messages.length; + if (entry.title === 'New conversation') { + const newTitle = deriveTitle(messages); + if (newTitle !== 'New conversation') entry.title = newTitle; + } + saveIndex(index); + } + } + + function startNewConversation(): void { + if (messages.length === 0 && !streamingText) return; + + if (abortController && streamingText) { + messages.push({ + role: 'assistant', + text: streamingText, + sources: streamingSources.length ? streamingSources : undefined, + }); + streamingText = ''; + streamingSources = []; + } + + if (abortController) { + conversationSwitchInProgress = true; + abortController.abort(); + abortController = null; + } + + saveHistory(); + createNewConversation(); + messages = []; + cancelStreamRender(); + + setViewMode('chat'); + renderMessages(); + input.focus(); + } + + function switchToConversation(id: string): void { + if (id === activeConversationId) { + setViewMode('chat'); + return; + } + + if (abortController && streamingText) { + messages.push({ + role: 'assistant', + text: streamingText, + sources: streamingSources.length ? streamingSources : undefined, + }); + streamingText = ''; + streamingSources = []; + } + + if (abortController) { + conversationSwitchInProgress = true; + abortController.abort(); + abortController = null; + } + + saveHistory(); + + activeConversationId = id; + setActiveId(id); + messages = loadConversation(id); + cancelStreamRender(); + + setViewMode('chat'); + renderMessages(); + input.focus(); + } + + ensureActiveConversation(); + messages = activeConversationId ? loadConversation(activeConversationId) : []; + + // ---- Build DOM ---- + + // FAB — telescope/orbit icon + const fab = document.createElement('button'); + fab.id = 'orrery-chat-fab'; + fab.className = 'chat-fab'; + fab.setAttribute('aria-label', 'Open orrery chat'); + fab.setAttribute('aria-expanded', 'false'); + fab.innerHTML = ` + + + `; + + // Panel + const panel = document.createElement('div'); + panel.className = 'chat-panel'; + panel.setAttribute('role', 'dialog'); + panel.setAttribute('aria-label', 'Orrery chat'); + panel.dataset.open = 'false'; + panel.dataset.view = 'chat'; + + // Header + const header = document.createElement('div'); + header.className = 'chat-header'; + + const backBtn = document.createElement('button'); + backBtn.className = 'chat-header-btn chat-header-back'; + backBtn.setAttribute('aria-label', 'Back to chat'); + backBtn.innerHTML = svgIcon(ICON_CHEVRON_LEFT); + backBtn.style.display = 'none'; + + const headerDot = document.createElement('span'); + headerDot.className = 'chat-header-dot'; + + const headerTitle = document.createElement('span'); + headerTitle.className = 'chat-header-title'; + headerTitle.textContent = 'Ask the Orrery'; + + const headerActions = document.createElement('div'); + headerActions.className = 'chat-header-actions'; + + const newBtn = document.createElement('button'); + newBtn.className = 'chat-header-btn'; + newBtn.setAttribute('aria-label', 'New conversation'); + newBtn.innerHTML = svgIcon(ICON_MESSAGE_PLUS); + + const historyBtn = document.createElement('button'); + historyBtn.className = 'chat-header-btn'; + historyBtn.setAttribute('aria-label', 'Conversation history'); + historyBtn.innerHTML = svgIcon(ICON_HISTORY); + + headerActions.appendChild(newBtn); + headerActions.appendChild(historyBtn); + + header.appendChild(backBtn); + header.appendChild(headerDot); + header.appendChild(headerTitle); + header.appendChild(headerActions); + + // Messages container + const messagesEl = document.createElement('div'); + messagesEl.className = 'chat-messages'; + messagesEl.setAttribute('aria-live', 'polite'); + + // Input area + const inputArea = document.createElement('div'); + inputArea.className = 'chat-input-area'; + + const input = document.createElement('input'); + input.className = 'chat-input'; + input.type = 'text'; + input.placeholder = 'Ask about celestial mechanics...'; + input.setAttribute('aria-label', 'Type your question'); + input.maxLength = 1000; + + const sendBtn = document.createElement('button'); + sendBtn.className = 'chat-send'; + sendBtn.textContent = 'Ask'; + sendBtn.disabled = true; + + inputArea.appendChild(input); + inputArea.appendChild(sendBtn); + + panel.appendChild(header); + panel.appendChild(messagesEl); + panel.appendChild(inputArea); + + document.body.appendChild(panel); + document.body.appendChild(fab); + + // ---- View management ---- + + function setViewMode(mode: 'chat' | 'history'): void { + viewMode = mode; + panel.dataset.view = mode; + + if (mode === 'history') { + backBtn.style.display = ''; + headerDot.style.display = 'none'; + headerTitle.textContent = 'Conversations'; + renderHistoryList(); + } else { + backBtn.style.display = 'none'; + headerDot.style.display = ''; + headerTitle.textContent = 'Ask the Orrery'; + clearPendingDelete(); + renderMessages(); + } + } + + function clearPendingDelete(): void { + if (pendingDeleteTimer !== null) { + clearTimeout(pendingDeleteTimer); + pendingDeleteTimer = null; + } + pendingDeleteId = null; + } + + // ---- Rendering helpers ---- + + function renderSourceLinks(sources: ChatSource[]): string { + if (!sources.length) return ''; + const links = sources + .filter((s) => /^https?:\/\//i.test(s.url) || s.url.startsWith('/')) + .map( + (s) => + `${escapeHtml(s.title)}`, + ) + .join(''); + return links ? `
${links}
` : ''; + } + + function renderMessages(): void { + messagesEl.innerHTML = ''; + + if (messages.length === 0) { + const suggestions = document.createElement('div'); + suggestions.className = 'chat-suggestions'; + for (const text of getSuggestions()) { + const chip = document.createElement('button'); + chip.className = 'chat-suggestion'; + chip.textContent = text; + chip.addEventListener('click', () => { + input.value = text; + sendQuestion(); + }); + suggestions.appendChild(chip); + } + messagesEl.appendChild(suggestions); + return; + } + + for (const msg of messages) { + const bubble = document.createElement('div'); + bubble.className = `chat-msg chat-msg-${msg.role}`; + bubble.innerHTML = renderMarkdownSafe(msg.text); + if (msg.role === 'assistant' && msg.sources?.length) { + bubble.insertAdjacentHTML('beforeend', renderSourceLinks(msg.sources)); + } + messagesEl.appendChild(bubble); + } + + messagesEl.scrollTop = messagesEl.scrollHeight; + } + + function renderHistoryList(): void { + messagesEl.innerHTML = ''; + + const index = loadIndex(); + + if (index.length === 0) { + const empty = document.createElement('div'); + empty.className = 'chat-history-empty'; + empty.innerHTML = ` + + No past conversations yet. + `; + messagesEl.appendChild(empty); + return; + } + + const list = document.createElement('div'); + list.className = 'chat-history-list'; + + for (const conv of index) { + const item = document.createElement('div'); + item.className = 'chat-history-item'; + item.setAttribute('role', 'button'); + item.setAttribute('tabindex', '0'); + if (conv.id === activeConversationId) item.dataset.active = ''; + if (conv.id === pendingDeleteId) item.dataset.confirm = ''; + + const content = document.createElement('div'); + content.className = 'chat-history-item-content'; + + const title = document.createElement('div'); + title.className = 'chat-history-item-title'; + title.textContent = conv.title; + + const meta = document.createElement('div'); + meta.className = 'chat-history-item-meta'; + + const count = document.createElement('span'); + count.className = 'chat-history-item-count'; + count.textContent = `${conv.messageCount} message${conv.messageCount !== 1 ? 's' : ''}`; + + const date = document.createElement('span'); + date.className = 'chat-history-item-date'; + date.textContent = formatRelativeDate(conv.updatedAt); + + meta.appendChild(count); + meta.appendChild(document.createTextNode(' \u00b7 ')); + meta.appendChild(date); + + content.appendChild(title); + content.appendChild(meta); + + const deleteBtn = document.createElement('button'); + deleteBtn.className = 'chat-history-delete'; + deleteBtn.setAttribute('aria-label', 'Delete conversation'); + + if (conv.id === pendingDeleteId) { + deleteBtn.innerHTML = 'delete?'; + } else { + deleteBtn.innerHTML = svgIcon(ICON_TRASH); + } + + item.addEventListener('click', (e) => { + if ((e.target as HTMLElement).closest('.chat-history-delete')) return; + switchToConversation(conv.id); + }); + + item.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + if ((e.target as HTMLElement).closest('.chat-history-delete')) return; + switchToConversation(conv.id); + } + }); + + deleteBtn.addEventListener('click', (e) => { + e.stopPropagation(); + handleDelete(conv.id); + }); + + item.appendChild(content); + item.appendChild(deleteBtn); + list.appendChild(item); + } + + messagesEl.appendChild(list); + } + + // ---- Two-click delete ---- + + function handleDelete(id: string): void { + if (pendingDeleteId === id) { + clearPendingDelete(); + performDelete(id); + } else { + clearPendingDelete(); + pendingDeleteId = id; + pendingDeleteTimer = window.setTimeout(() => { + pendingDeleteId = null; + pendingDeleteTimer = null; + if (viewMode === 'history') renderHistoryList(); + }, 3000); + renderHistoryList(); + } + } + + function performDelete(id: string): void { + let index = loadIndex(); + index = index.filter((c) => c.id !== id); + saveIndex(index); + removeConversationData(id); + + if (id === activeConversationId) { + if (index.length > 0) { + activeConversationId = index[0].id; + setActiveId(activeConversationId); + messages = loadConversation(activeConversationId); + } else { + createNewConversation(); + messages = []; + setViewMode('chat'); + return; + } + } + + renderHistoryList(); + } + + // ---- Thinking indicator ---- + + function showThinking(): HTMLElement { + const el = document.createElement('div'); + el.className = 'chat-thinking'; + el.innerHTML = ` +
+ +
+ + `; + messagesEl.appendChild(el); + messagesEl.scrollTop = messagesEl.scrollHeight; + + const timer = setTimeout(() => { + const hint = el.querySelector('.chat-thinking-text'); + if (hint) hint.textContent = 'This can take a moment\u2026'; + }, 5000); + el.dataset.timer = String(timer); + + return el; + } + + function removeThinking(el: HTMLElement): void { + clearTimeout(Number(el.dataset.timer)); + el.remove(); + } + + // ---- Page context ---- + + function getPageContext(): { title: string; path: string; description: string } | null { + const title = document.querySelector('main h1')?.textContent?.trim(); + if (!title) return null; + return { + title, + path: location.pathname, + description: + document.querySelector('meta[name="description"]')?.content || '', + }; + } + + // ---- SSE helpers ---- + + function updateThinkingStatus(el: HTMLElement, text: string): void { + const hint = el.querySelector('.chat-thinking-text'); + if (hint) hint.textContent = text; + } + + function tryParseJSON(raw: string): unknown | null { + try { + return JSON.parse(raw); + } catch { + return null; + } + } + + function parseSSEBlock(raw: string): { event: string; data: string } | null { + let event = ''; + let data = ''; + for (const line of raw.split('\n')) { + if (line.startsWith('event: ')) event = line.slice(7); + else if (line.startsWith('data: ')) data = line.slice(6); + } + return event && data ? { event, data } : null; + } + + // ---- Send question ---- + + async function sendQuestion(): Promise { + const question = input.value.trim(); + if (!question) return; + + conversationSwitchInProgress = false; + + if (question.length > 1000) { + messages.push({ + role: 'assistant', + text: 'That question is too long (max 1,000 characters). Please shorten it and try again.', + }); + saveHistory(); + renderMessages(); + return; + } + + if (abortController && streamingText) { + messages.push({ + role: 'assistant', + text: streamingText, + sources: streamingSources.length ? streamingSources : undefined, + }); + streamingText = ''; + streamingSources = []; + } + + if (abortController) { + conversationSwitchInProgress = true; + abortController.abort(); + } + abortController = new AbortController(); + cancelStreamRender(); + const timeout = setTimeout(() => abortController!.abort(), 120_000); + + messages.push({ role: 'user', text: question }); + saveHistory(); + input.value = ''; + sendBtn.disabled = true; + renderMessages(); + + const thinking = showThinking(); + + let fullText = ''; + let reasoningText = ''; + let reasoningStart = 0; + let localSources: ChatSource[] = []; + let bubble: HTMLElement | null = null; + + try { + const body: Record = { question }; + const page = getPageContext(); + if (page) body.page = page; + + const resp = await fetch('/api/chat/stream', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: abortController.signal, + }); + + if (!resp.ok || !resp.body) { + clearTimeout(timeout); + removeThinking(thinking); + messages.push({ + role: 'assistant', + text: + resp.status >= 400 && resp.status < 500 + ? 'Invalid request. Try rephrasing your question.' + : 'The orrery is temporarily unavailable. Please try again in a moment.', + }); + saveHistory(); + renderMessages(); + streamingText = ''; + streamingSources = []; + return; + } + + const reader = resp.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + + let boundary: number; + while ((boundary = buffer.indexOf('\n\n')) !== -1) { + const raw = buffer.slice(0, boundary); + buffer = buffer.slice(boundary + 2); + + const evt = parseSSEBlock(raw); + if (!evt) continue; + + if (evt.event === 'status') { + const parsed = tryParseJSON(evt.data) as { text?: string } | null; + if (parsed?.text) updateThinkingStatus(thinking, parsed.text); + } else if (evt.event === 'sources') { + const parsed = tryParseJSON(evt.data); + if (Array.isArray(parsed)) { + localSources = parsed; + streamingSources = parsed; + } + } else if (evt.event === 'reasoning') { + const parsed = tryParseJSON(evt.data) as { text?: string } | null; + if (!parsed?.text) continue; + if (!reasoningStart) reasoningStart = Date.now(); + reasoningText += parsed.text; + updateThinkingStatus(thinking, reasoningText.slice(-80).replace(/\n/g, ' ')); + } else if (evt.event === 'token') { + const parsed = tryParseJSON(evt.data) as { text?: string } | null; + if (!parsed?.text) continue; + fullText += parsed.text; + + streamingText = fullText; + + if (!bubble) { + removeThinking(thinking); + if (reasoningText) { + const secs = Math.round((Date.now() - reasoningStart) / 1000); + const details = document.createElement('details'); + details.className = 'chat-reasoning'; + details.innerHTML = + `Thought for ${secs}s` + + `
${escapeHtml(reasoningText).replace(/\n/g, '
')}
`; + messagesEl.appendChild(details); + } + bubble = document.createElement('div'); + bubble.className = 'chat-msg chat-msg-assistant'; + messagesEl.appendChild(bubble); + } + scheduleStreamRender(bubble, fullText); + } else if (evt.event === 'done') { + clearTimeout(timeout); + if (bubble) { + cancelStreamRender(); + bubble.innerHTML = renderMarkdownSafe(fullText); + if (localSources.length) { + bubble.insertAdjacentHTML('beforeend', renderSourceLinks(localSources)); + } + } else { + removeThinking(thinking); + fullText = 'The model was unable to generate an answer. Try rephrasing your question.'; + bubble = document.createElement('div'); + bubble.className = 'chat-msg chat-msg-assistant'; + bubble.innerHTML = escapeHtml(fullText); + messagesEl.appendChild(bubble); + } + messages.push({ + role: 'assistant', + text: fullText, + sources: localSources.length ? localSources : undefined, + }); + saveHistory(); + messagesEl.scrollTop = messagesEl.scrollHeight; + streamingText = ''; + streamingSources = []; + return; + } else if (evt.event === 'error') { + const parsed = tryParseJSON(evt.data) as { text?: string } | null; + clearTimeout(timeout); + removeThinking(thinking); + messages.push({ + role: 'assistant', + text: parsed?.text || 'An error occurred.', + }); + saveHistory(); + renderMessages(); + streamingText = ''; + streamingSources = []; + return; + } + } + } + + clearTimeout(timeout); + if (bubble) { + bubble.innerHTML = renderMarkdownSafe(fullText); + if (localSources.length) { + bubble.insertAdjacentHTML('beforeend', renderSourceLinks(localSources)); + } + } else { + removeThinking(thinking); + } + if (fullText) { + messages.push({ + role: 'assistant', + text: fullText, + sources: localSources.length ? localSources : undefined, + }); + } else { + messages.push({ + role: 'assistant', + text: 'The response ended unexpectedly. Please try again.', + }); + } + saveHistory(); + renderMessages(); + streamingText = ''; + streamingSources = []; + } catch (err) { + clearTimeout(timeout); + streamingText = ''; + streamingSources = []; + + if (conversationSwitchInProgress) { + conversationSwitchInProgress = false; + return; + } + + if (bubble && fullText) { + bubble.innerHTML = renderMarkdownSafe(fullText); + messages.push({ + role: 'assistant', + text: fullText, + sources: localSources.length ? localSources : undefined, + }); + } else { + removeThinking(thinking); + if (err instanceof DOMException && err.name === 'AbortError') return; + messages.push({ + role: 'assistant', + text: 'Unable to reach the orrery. Please check your connection and try again.', + }); + } + saveHistory(); + renderMessages(); + } + } + + // ---- Toggle panel ---- + + function openPanel(): void { + panelOpen = true; + panel.dataset.open = 'true'; + fab.setAttribute('aria-expanded', 'true'); + if (viewMode === 'chat') { + renderMessages(); + input.focus(); + } else { + renderHistoryList(); + } + } + + function closePanel(): void { + panelOpen = false; + panel.dataset.open = 'false'; + fab.setAttribute('aria-expanded', 'false'); + clearPendingDelete(); + fab.focus(); + } + + fab.addEventListener('click', () => { + if (panelOpen) closePanel(); + else openPanel(); + }); + + // ---- Header button handlers ---- + + newBtn.addEventListener('click', startNewConversation); + + historyBtn.addEventListener('click', () => { + if (viewMode === 'history') { + setViewMode('chat'); + } else { + setViewMode('history'); + } + }); + + backBtn.addEventListener('click', () => { + setViewMode('chat'); + }); + + // ---- Input handlers ---- + + input.addEventListener('input', () => { + sendBtn.disabled = !input.value.trim(); + }); + + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && input.value.trim()) { + e.preventDefault(); + sendQuestion(); + } + }); + + sendBtn.addEventListener('click', sendQuestion); + + // ---- Keyboard: Escape ---- + + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape' && panelOpen) { + e.preventDefault(); + if (viewMode === 'history') { + setViewMode('chat'); + } else { + closePanel(); + } + } + }; + document.addEventListener('keydown', handleEscape); + + cleanupPreviousInit = () => { + document.removeEventListener('keydown', handleEscape); + }; + + renderMessages(); +} diff --git a/docs/src/styles/chat-widget.css b/docs/src/styles/chat-widget.css new file mode 100644 index 0000000..f7e8d80 --- /dev/null +++ b/docs/src/styles/chat-widget.css @@ -0,0 +1,889 @@ +/* ============================================ + pg_orrery — Chat Widget + FAB + sliding panel for docs Q&A + ============================================ */ + +/* ---- FAB (floating action button) ---- */ +.chat-fab { + position: fixed; + bottom: 1.5rem; + right: 1.5rem; + z-index: 1000; + width: 3.25rem; + height: 3.25rem; + border-radius: 50%; + border: none; + background: var(--sl-color-accent); + color: var(--sl-color-black); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3); + transition: transform 0.15s ease, box-shadow 0.15s ease; +} + +.chat-fab:hover { + transform: scale(1.08); + box-shadow: 0 4px 20px rgba(245, 158, 11, 0.3); +} + +.chat-fab:active { + transform: scale(0.96); +} + +.chat-fab svg { + width: 1.25rem; + height: 1.25rem; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +/* X icon when panel is open */ +.chat-fab[aria-expanded="true"] svg.icon-message { display: none; } +.chat-fab[aria-expanded="true"] svg.icon-close { display: block; } +.chat-fab[aria-expanded="false"] svg.icon-message { display: block; } +.chat-fab[aria-expanded="false"] svg.icon-close { display: none; } + +/* ---- Panel ---- */ +.chat-panel { + position: fixed; + bottom: 5.5rem; + right: 1.5rem; + z-index: 999; + width: 24rem; + max-height: 70vh; + display: flex; + flex-direction: column; + background: var(--sl-color-bg-nav); + border: 1px solid var(--sl-color-hairline-light); + border-radius: 0.75rem; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); + transform: translateY(0.5rem); + opacity: 0; + pointer-events: none; + transition: transform 0.2s ease, opacity 0.2s ease; +} + +.chat-panel[data-open="true"] { + transform: translateY(0); + opacity: 1; + pointer-events: auto; +} + +/* ---- Panel header ---- */ +.chat-header { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--sl-color-hairline-shade); + font-family: var(--sl-font-headings); + font-size: 0.85rem; + font-weight: 600; + color: var(--sl-color-white); +} + +.chat-header-dot { + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background: var(--sl-color-accent); + flex-shrink: 0; +} + +.chat-header-title { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-header-actions { + display: flex; + gap: 0.25rem; + margin-left: auto; + flex-shrink: 0; +} + +.chat-header-btn { + width: 1.75rem; + height: 1.75rem; + padding: 0; + border: none; + background: transparent; + color: var(--sl-color-gray-3); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + border-radius: 0.25rem; + transition: color 0.15s ease, background 0.15s ease; +} + +.chat-header-btn:hover { + color: var(--sl-color-white); + background: var(--sl-color-gray-5); +} + +.chat-header-btn:active { + transform: scale(0.92); +} + +.chat-header-btn svg { + width: 1rem; + height: 1rem; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +/* ---- Messages area ---- */ +.chat-messages { + flex: 1; + overflow-y: auto; + padding: 0.75rem; + display: flex; + flex-direction: column; + gap: 0.5rem; + min-height: 8rem; + scroll-behavior: smooth; +} + +.chat-messages::-webkit-scrollbar { + width: 4px; +} + +.chat-messages::-webkit-scrollbar-track { + background: transparent; +} + +.chat-messages::-webkit-scrollbar-thumb { + background: var(--sl-color-gray-4); + border-radius: 2px; +} + +/* ---- Message bubbles ---- */ +.chat-msg { + max-width: 88%; + padding: 0.5rem 0.75rem; + border-radius: 0.5rem; + font-size: 0.85rem; + line-height: 1.5; + color: var(--sl-color-white); + word-wrap: break-word; + overflow-wrap: break-word; +} + +.chat-msg a { + color: var(--sl-color-accent); + text-decoration: underline; + text-underline-offset: 2px; +} + +.chat-msg a:hover { + color: var(--sl-color-accent-high); +} + +.chat-msg-user { + align-self: flex-end; + background: var(--sl-color-accent-low); + border: 1px solid var(--sl-color-hairline-light); +} + +.chat-msg-assistant { + align-self: flex-start; + background: var(--sl-color-gray-6); + max-width: 100%; +} + +.chat-msg-assistant strong { + color: var(--sl-color-accent-high); +} + +.chat-msg-assistant code { + font-family: var(--sl-font-mono); + font-size: 0.82em; + background: var(--sl-color-gray-5); + padding: 0.1em 0.3em; + border-radius: 0.2rem; +} + +/* ---- Markdown elements in assistant bubbles ---- */ +.chat-msg-assistant p { + margin: 0 0 0.4em; +} + +.chat-msg-assistant p:last-child { + margin-bottom: 0; +} + +.chat-msg-assistant h1, +.chat-msg-assistant h2, +.chat-msg-assistant h3, +.chat-msg-assistant h4, +.chat-msg-assistant h5, +.chat-msg-assistant h6 { + font-family: var(--sl-font-headings); + margin: 0.6em 0 0.3em; + line-height: 1.3; + color: var(--sl-color-accent-high); +} + +.chat-msg-assistant h1 { font-size: 1.1em; } +.chat-msg-assistant h2 { font-size: 1.05em; } +.chat-msg-assistant h3 { font-size: 1em; } +.chat-msg-assistant h4, +.chat-msg-assistant h5, +.chat-msg-assistant h6 { font-size: 0.9em; } + +.chat-msg-assistant h1:first-child, +.chat-msg-assistant h2:first-child, +.chat-msg-assistant h3:first-child { + margin-top: 0; +} + +.chat-msg-assistant ul, +.chat-msg-assistant ol { + margin: 0.3em 0; + padding-left: 1.4em; +} + +.chat-msg-assistant li { + margin: 0.15em 0; +} + +.chat-msg-assistant li > ul, +.chat-msg-assistant li > ol { + margin: 0.1em 0; +} + +.chat-msg-assistant blockquote { + margin: 0.4em 0; + padding: 0.3em 0.6em; + border-left: 3px solid var(--sl-color-accent); + background: var(--sl-color-gray-5); + border-radius: 0 0.25rem 0.25rem 0; + font-style: italic; +} + +.chat-msg-assistant blockquote p { + margin: 0; +} + +.chat-msg-assistant pre { + margin: 0.4em 0; + padding: 0.5em 0.65em; + background: var(--sl-color-gray-5); + border-radius: 0.375rem; + overflow-x: auto; + font-size: 0.82em; + line-height: 1.45; +} + +.chat-msg-assistant pre code { + background: none; + padding: 0; + border-radius: 0; + font-size: inherit; +} + +.chat-msg-assistant table { + width: 100%; + border-collapse: collapse; + margin: 0.4em 0; + font-size: 0.82em; +} + +.chat-msg-assistant th, +.chat-msg-assistant td { + padding: 0.3em 0.5em; + border: 1px solid var(--sl-color-hairline-shade); + text-align: left; +} + +.chat-msg-assistant th { + background: var(--sl-color-gray-5); + font-weight: 600; +} + +.chat-msg-assistant tbody tr:nth-child(even) { + background: rgba(255, 255, 255, 0.03); +} + +.chat-msg-assistant hr { + border: none; + border-top: 1px solid var(--sl-color-hairline-shade); + margin: 0.5em 0; +} + +.chat-msg-assistant del, +.chat-msg-assistant s { + opacity: 0.65; +} + +.chat-msg-assistant input[type="checkbox"] { + pointer-events: none; + margin-right: 0.3em; +} + +/* ---- Sources below assistant message ---- */ +.chat-sources { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin-top: 0.35rem; + padding-top: 0.35rem; + border-top: 1px solid var(--sl-color-hairline-shade); +} + +.chat-source-link { + font-size: 0.72rem; + color: var(--sl-color-accent); + text-decoration: none; + padding: 0.15rem 0.4rem; + border-radius: 0.25rem; + background: var(--sl-color-accent-low); + transition: background 0.1s ease; + white-space: nowrap; +} + +.chat-source-link:hover { + background: var(--sl-color-hairline-light); + color: var(--sl-color-accent-high); +} + +/* ---- Thinking indicator ---- */ +.chat-thinking { + align-self: flex-start; + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; +} + +.chat-thinking-dots { + display: flex; + gap: 0.25rem; +} + +.chat-thinking-dots span { + width: 0.4rem; + height: 0.4rem; + border-radius: 50%; + background: var(--sl-color-gray-3); + animation: chat-pulse 1.4s ease-in-out infinite; +} + +.chat-thinking-dots span:nth-child(2) { + animation-delay: 0.2s; +} + +.chat-thinking-dots span:nth-child(3) { + animation-delay: 0.4s; +} + +@keyframes chat-pulse { + 0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); } + 40% { opacity: 1; transform: scale(1); } +} + +.chat-thinking-text { + font-size: 0.75rem; + color: var(--sl-color-gray-3); + transition: opacity 0.15s ease; + max-width: 16rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-style: italic; +} + +/* ---- Reasoning (collapsed thinking) ---- */ +.chat-reasoning { + align-self: flex-start; + margin: 0.25rem 0; + font-size: 0.75rem; + color: var(--sl-color-gray-3); +} + +.chat-reasoning summary { + cursor: pointer; + font-style: italic; + user-select: none; + list-style: none; + display: flex; + align-items: center; + gap: 0.35rem; +} + +.chat-reasoning summary::before { + content: '\25B6'; + font-size: 0.5rem; + transition: transform 0.15s ease; +} + +.chat-reasoning[open] summary::before { + transform: rotate(90deg); +} + +.chat-reasoning-body { + margin-top: 0.35rem; + padding: 0.5rem 0.625rem; + background: var(--sl-color-gray-6); + border-radius: 0.375rem; + font-size: 0.7rem; + line-height: 1.45; + max-height: 10rem; + overflow-y: auto; + color: var(--sl-color-gray-2); + font-style: italic; +} + +/* ---- Suggestion chips ---- */ +.chat-suggestions { + display: flex; + flex-direction: column; + gap: 0.35rem; + padding: 0.25rem 0; +} + +.chat-suggestion { + background: none; + border: 1px solid var(--sl-color-hairline-light); + border-radius: 0.5rem; + padding: 0.45rem 0.65rem; + font-size: 0.8rem; + color: var(--sl-color-gray-2); + cursor: pointer; + text-align: left; + font-family: var(--sl-font); + transition: border-color 0.15s ease, color 0.15s ease; +} + +.chat-suggestion:hover { + border-color: var(--sl-color-accent); + color: var(--sl-color-accent); +} + +/* ---- Input area ---- */ +.chat-input-area { + display: flex; + gap: 0.5rem; + padding: 0.65rem 0.75rem; + border-top: 1px solid var(--sl-color-hairline-shade); +} + +.chat-input { + flex: 1; + background: var(--sl-color-gray-6); + border: 1px solid var(--sl-color-hairline-light); + border-radius: 0.4rem; + padding: 0.45rem 0.65rem; + font-size: 0.85rem; + font-family: var(--sl-font); + color: var(--sl-color-white); + outline: none; + transition: border-color 0.15s ease; +} + +.chat-input::placeholder { + color: var(--sl-color-gray-3); +} + +.chat-input:focus { + border-color: var(--sl-color-accent); +} + +.chat-send { + background: var(--sl-color-accent); + color: var(--sl-color-black); + border: none; + border-radius: 0.4rem; + padding: 0.45rem 0.65rem; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + font-family: var(--sl-font); + transition: opacity 0.1s ease; +} + +.chat-send:hover { + opacity: 0.9; +} + +.chat-send:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* ---- History list ---- */ +.chat-history-list { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.chat-history-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.6rem 0.75rem; + border-radius: 0.4rem; + cursor: pointer; + border-left: 3px solid transparent; + transition: background 0.15s ease, border-color 0.15s ease; +} + +.chat-history-item:hover { + background: var(--sl-color-gray-6); +} + +.chat-history-item:focus-visible { + outline: 2px solid var(--sl-color-accent); + outline-offset: -2px; +} + +.chat-history-item[data-active] { + border-left-color: var(--sl-color-accent); +} + +.chat-history-item[data-confirm] { + background: rgba(220, 50, 50, 0.08); + border-left-color: #dc3232; +} + +.chat-history-item-content { + flex: 1; + min-width: 0; +} + +.chat-history-item-title { + font-size: 0.82rem; + color: var(--sl-color-white); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.4; +} + +.chat-history-item-meta { + font-size: 0.72rem; + color: var(--sl-color-gray-3); + margin-top: 0.1rem; +} + +.chat-history-delete { + width: 1.5rem; + height: 1.5rem; + padding: 0; + border: none; + background: transparent; + color: var(--sl-color-gray-4); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + border-radius: 0.25rem; + opacity: 0; + transition: opacity 0.15s ease, color 0.15s ease; + flex-shrink: 0; +} + +.chat-history-item:hover .chat-history-delete, +.chat-history-item[data-confirm] .chat-history-delete { + opacity: 1; +} + +.chat-history-delete:hover { + color: #dc3232; +} + +.chat-history-delete svg { + width: 0.85rem; + height: 0.85rem; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +.chat-history-delete-confirm { + font-size: 0.7rem; + color: #dc3232; + font-weight: 600; + white-space: nowrap; +} + +/* ---- Empty state ---- */ +.chat-history-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 2rem 1rem; + gap: 0.75rem; +} + +.chat-history-empty-icon { + width: 2rem; + height: 2rem; + fill: none; + stroke: var(--sl-color-gray-4); + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; + opacity: 0.6; +} + +.chat-history-empty-hint { + font-size: 0.82rem; + color: var(--sl-color-gray-3); +} + +/* ---- View toggle: hide input in history mode ---- */ +.chat-panel[data-view="history"] .chat-input-area { + display: none; +} + +/* ---- Mobile: full-screen panel ---- */ +@media (max-width: 40rem) { + .chat-fab { + bottom: 1rem; + right: 1rem; + width: 2.75rem; + height: 2.75rem; + } + + .chat-fab svg { + width: 1.1rem; + height: 1.1rem; + } + + .chat-panel { + inset: 0; + width: 100%; + max-height: 100%; + border-radius: 0; + bottom: 0; + right: 0; + } + + .chat-panel[data-open="true"] { + transform: translateY(0); + } + + .chat-panel[data-open="true"] ~ .chat-fab { + bottom: 0.45rem; + } + + .chat-input-area { + padding-right: 4.25rem; + } +} + +/* ---- Light theme overrides ---- */ +:root[data-theme='light'] .chat-panel { + background: #fdfcf9; + border-color: rgba(245, 158, 11, 0.2); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12); +} + +:root[data-theme='light'] .chat-header { + color: #1a1a2e; + border-bottom-color: rgba(245, 158, 11, 0.15); +} + +:root[data-theme='light'] .chat-header-btn { + color: #94a3b8; +} + +:root[data-theme='light'] .chat-header-btn:hover { + color: #1a1a2e; + background: #f1f5f9; +} + +:root[data-theme='light'] .chat-msg { + color: #1a1a2e; +} + +:root[data-theme='light'] .chat-msg-user { + background: #fef3c7; + border-color: rgba(245, 158, 11, 0.2); +} + +:root[data-theme='light'] .chat-msg-assistant { + background: #f8fafc; +} + +:root[data-theme='light'] .chat-msg-assistant strong { + color: #b45309; +} + +:root[data-theme='light'] .chat-msg-assistant code { + background: #f1f5f9; +} + +:root[data-theme='light'] .chat-msg-assistant h1, +:root[data-theme='light'] .chat-msg-assistant h2, +:root[data-theme='light'] .chat-msg-assistant h3, +:root[data-theme='light'] .chat-msg-assistant h4, +:root[data-theme='light'] .chat-msg-assistant h5, +:root[data-theme='light'] .chat-msg-assistant h6 { + color: #b45309; +} + +:root[data-theme='light'] .chat-msg-assistant blockquote { + background: #f1f5f9; + border-left-color: #f59e0b; +} + +:root[data-theme='light'] .chat-msg-assistant pre { + background: #f1f5f9; +} + +:root[data-theme='light'] .chat-msg-assistant th { + background: #f1f5f9; +} + +:root[data-theme='light'] .chat-msg-assistant th, +:root[data-theme='light'] .chat-msg-assistant td { + border-color: rgba(245, 158, 11, 0.2); +} + +:root[data-theme='light'] .chat-msg-assistant tbody tr:nth-child(even) { + background: rgba(245, 158, 11, 0.04); +} + +:root[data-theme='light'] .chat-msg-assistant hr { + border-top-color: rgba(245, 158, 11, 0.15); +} + +:root[data-theme='light'] .chat-msg a { + color: #d97706; +} + +:root[data-theme='light'] .chat-msg a:hover { + color: #92400e; +} + +:root[data-theme='light'] .chat-sources { + border-top-color: rgba(245, 158, 11, 0.15); +} + +:root[data-theme='light'] .chat-source-link { + color: #d97706; + background: #fef3c7; +} + +:root[data-theme='light'] .chat-source-link:hover { + background: rgba(245, 158, 11, 0.15); + color: #92400e; +} + +:root[data-theme='light'] .chat-input { + background: #f8fafc; + border-color: rgba(245, 158, 11, 0.2); + color: #1a1a2e; +} + +:root[data-theme='light'] .chat-input::placeholder { + color: #94a3b8; +} + +:root[data-theme='light'] .chat-input-area { + border-top-color: rgba(245, 158, 11, 0.15); +} + +:root[data-theme='light'] .chat-send { + background: #f59e0b; + color: #1a1a2e; +} + +:root[data-theme='light'] .chat-suggestion { + color: #475569; + border-color: rgba(245, 158, 11, 0.2); +} + +:root[data-theme='light'] .chat-suggestion:hover { + border-color: #f59e0b; + color: #d97706; +} + +:root[data-theme='light'] .chat-thinking-dots span { + background: #94a3b8; +} + +:root[data-theme='light'] .chat-thinking-text { + color: #94a3b8; +} + +:root[data-theme='light'] .chat-reasoning { + color: #94a3b8; +} + +:root[data-theme='light'] .chat-reasoning-body { + background: #f8fafc; + color: #64748b; +} + +:root[data-theme='light'] .chat-messages::-webkit-scrollbar-thumb { + background: #94a3b8; +} + +:root[data-theme='light'] .chat-history-item:hover { + background: #f8fafc; +} + +:root[data-theme='light'] .chat-history-item[data-active] { + border-left-color: #f59e0b; +} + +:root[data-theme='light'] .chat-history-item[data-confirm] { + background: rgba(220, 50, 50, 0.06); +} + +:root[data-theme='light'] .chat-history-item-title { + color: #1a1a2e; +} + +:root[data-theme='light'] .chat-history-item-meta { + color: #94a3b8; +} + +:root[data-theme='light'] .chat-history-delete { + color: #94a3b8; +} + +:root[data-theme='light'] .chat-history-empty-icon { + stroke: #94a3b8; +} + +:root[data-theme='light'] .chat-history-empty-hint { + color: #94a3b8; +} + +/* ---- Reduced motion ---- */ +@media (prefers-reduced-motion: reduce) { + .chat-fab, + .chat-panel, + .chat-header-btn, + .chat-history-item, + .chat-history-delete { + transition-duration: 1ms; + } + + .chat-thinking-dots span { + animation-duration: 1ms; + } + + .chat-messages { + scroll-behavior: auto; + } +}