feat: Add streaming chat + scroll persistence; improve markdown & links

Backend
- /chat: support streaming via StreamingResponse; save full reply after stream ends. Non-stream path unchanged.
- ChatRequest: add stream flag (default false).
- GenerateTitleRequest: add model and use it instead of hardcoded llama3.
- ollama_client.chat_stream(): new async generator parsing Ollama streaming JSON (both formats).
- Remove response_model from /chat to allow streaming; non-stream still returns { reply }.

Electron
- Open external links in system browser (setWindowOpenHandler, shell.openExternal).
- New IPC: update-settings, open-external-link.
- Set minimum window size; preload exposes updateSettings and openExternalLink.

Frontend (React)
- Streaming UI with live chunking; sticky-bottom only when user at bottom.
- Per-session scroll persistence and robust restore.
- New message tip to jump to latest reply when scrolled up.
- Disable Send while sending; spinner.
- General Settings: stream output toggle; propagate model/stream changes.
- Apply color scheme at boot; extract colorSchemes helper.
- Sidebar UX tweaks and unread badges.

Markdown/rendering
- Code blocks: language title bar and wrapper.
- Tables: GitHub-style parsing, per-cell borders, rounded wrapper, spacing, alignment.
- Headings: remove blank line after h1-h4.
- <hr>: handle after tables; strip following whitespace.
- Links: target=_blank with icon and URL tooltip.

Styles
- Add styles for code/table wrappers, new-message tip, toggle, spinner; hover/active vars; narrower sidebar.

API notes / breaking changes
- /chat accepts stream=true and returns text/plain streamed chunks.
- generate-title now requires a model.
- Non-stream /chat response shape unchanged.
This commit is contained in:
2025-08-23 16:45:46 +02:00
parent 191fe98ac0
commit 41c69abe28
12 changed files with 897 additions and 144 deletions

View File

@@ -1,10 +1,11 @@
from fastapi import FastAPI, Depends, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from typing import List
from . import models, schemas
from .database import Base, engine, SessionLocal
from .ollama_client import list_models as ollama_list, chat as ollama_chat
from .ollama_client import list_models as ollama_list, chat as ollama_chat, chat_stream as ollama_chat_stream
# Create tables
Base.metadata.create_all(bind=engine)
@@ -61,7 +62,7 @@ def history(session_id: str, db: Session = Depends(get_db)):
msgs = [{"role": r.role, "content": r.content} for r in rows]
return {"messages": msgs}
@app.post("/chat", response_model=schemas.ChatResponse)
@app.post("/chat")
async def chat(req: schemas.ChatRequest, db: Session = Depends(get_db)):
# Find or create session
session = db.query(models.ChatSession).filter(models.ChatSession.session_id == req.session_id).first()
@@ -81,17 +82,35 @@ async def chat(req: schemas.ChatRequest, db: Session = Depends(get_db)):
messages = [{"role": m.role, "content": m.content} for m in last_msgs]
try:
reply = await ollama_chat(req.model, messages)
except Exception as e:
raise HTTPException(status_code=502, detail=f"Ollama error: {e}")
if req.stream:
async def stream_generator():
full_reply = ""
try:
async for chunk in ollama_chat_stream(req.model, messages):
full_reply += chunk
yield chunk
except Exception as e:
# How to handle errors in a stream? Could yield an error message.
yield f"Ollama error: {e}"
# Save assistant reply
as_row = models.ChatMessage(session_pk=session.id, role='assistant', content=reply)
db.add(as_row)
db.commit()
# Save full reply after stream is complete
as_row = models.ChatMessage(session_pk=session.id, role='assistant', content=full_reply)
db.add(as_row)
db.commit()
return StreamingResponse(stream_generator(), media_type="text/plain")
else:
try:
reply = await ollama_chat(req.model, messages)
except Exception as e:
raise HTTPException(status_code=502, detail=f"Ollama error: {e}")
return {"reply": reply}
# Save assistant reply
as_row = models.ChatMessage(session_pk=session.id, role='assistant', content=reply)
db.add(as_row)
db.commit()
return {"reply": reply}
@app.post("/generate-title", response_model=schemas.GenerateTitleResponse)
async def generate_title(req: schemas.GenerateTitleRequest, db: Session = Depends(get_db)):
@@ -102,7 +121,7 @@ async def generate_title(req: schemas.GenerateTitleRequest, db: Session = Depend
prompt = f"Generate a very short, concise title (5 words or less) for a chat conversation that begins with this user message: \"{req.message}\". Do not use quotation marks in the title."
try:
title = await ollama_chat("llama3", [{"role": "user", "content": prompt}])
title = await ollama_chat(req.model, [{"role": "user", "content": prompt}])
except Exception as e:
raise HTTPException(status_code=502, detail=f"Ollama error: {e}")

View File

@@ -1,6 +1,7 @@
import httpx
from typing import Dict, Any, List
import json
from typing import Dict, Any, List, AsyncGenerator
OLLAMA_URL = "http://127.0.0.1:11434"
@@ -32,3 +33,23 @@ async def chat(model: str, messages: List[Dict[str, str]]) -> str:
if msgs:
return msgs[-1].get("content", "")
return data.get("content", "")
async def chat_stream(model: str, messages: List[Dict[str, str]]) -> AsyncGenerator[str, None]:
payload = {
"model": model,
"messages": messages,
"stream": True
}
async with httpx.AsyncClient(timeout=600.0) as client:
async with client.stream("POST", f"{OLLAMA_URL}/api/chat", json=payload) as r:
r.raise_for_status()
async for line in r.aiter_lines():
if line:
try:
chunk = json.loads(line)
if "content" in chunk: # Newer Ollama format
yield chunk["content"]
elif "message" in chunk and "content" in chunk["message"]: # Older format
yield chunk["message"]["content"]
except json.JSONDecodeError:
pass # Ignore invalid JSON lines

View File

@@ -10,6 +10,7 @@ class ChatRequest(BaseModel):
session_id: str
model: str
message: str
stream: Optional[bool] = False
class ChatResponse(BaseModel):
reply: str
@@ -20,6 +21,7 @@ class HistoryResponse(BaseModel):
class GenerateTitleRequest(BaseModel):
session_id: str
message: str
model: str
class GenerateTitleResponse(BaseModel):
title: str

View File

@@ -1,5 +1,5 @@
const { app, BrowserWindow, Menu, ipcMain } = require('electron')
const { app, BrowserWindow, Menu, ipcMain, shell } = require('electron')
const path = require('path')
const { is } = require('@electron-toolkit/utils')
const fs = require('fs')
@@ -44,6 +44,8 @@ async function createMainWindow () {
mainWindow = new BrowserWindow({
width: 1000,
height: 720,
minWidth: 680,
minHeight: 300,
show: false,
webPreferences: {
preload: path.join(__dirname, 'preload.cjs'),
@@ -62,6 +64,11 @@ async function createMainWindow () {
} else {
await mainWindow.loadFile(path.join(__dirname, '../dist/index.html'))
}
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: 'deny' };
});
}
async function createSettingsWindow () {
@@ -166,6 +173,16 @@ ipcMain.handle('set-setting', (event, key, value) => {
return true
})
ipcMain.handle('update-settings', (event, settings) => {
appSettings = { ...appSettings, ...settings }
saveSettings()
return true
})
ipcMain.on('open-external-link', (event, url) => {
shell.openExternal(url)
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})

View File

@@ -4,5 +4,11 @@ const { contextBridge, ipcRenderer } = require('electron')
// Expose a secure API to the renderer process
contextBridge.exposeInMainWorld('electronAPI', {
getSettings: () => ipcRenderer.invoke('get-settings'),
setSetting: (key, value) => ipcRenderer.invoke('set-setting', key, value)
setSetting: (key, value) => ipcRenderer.invoke('set-setting', key, value),
updateSettings: (settings) => ipcRenderer.invoke('update-settings', settings),
openExternalLink: (event) => {
event.preventDefault();
const url = event.currentTarget.href;
ipcRenderer.send('open-external-link', url);
}
})

View File

@@ -1,4 +1,6 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
// /Users/giers/Heimgeist/src/App.jsx
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { flushSync } from 'react-dom';
import TextareaAutosize from 'react-textarea-autosize';
import GeneralSettings from './GeneralSettings'
import InterfaceSettings from './InterfaceSettings'
@@ -9,6 +11,8 @@ const COLOR_SCHEME_KEY = 'colorScheme';
// Initial API value will be set by useEffect after settings are loaded
let API = import.meta.env.VITE_API_URL ?? 'http://127.0.0.1:8000';
const TOP_ALIGN_OFFSET = 48; // match .chat padding + header height for exact top alignment (should be more dynamic depending on header height)
const BOTTOM_EPSILON = 24; // px tolerance for treating as bottom
export default function App() {
const [chatSessions, setChatSessions] = useState([])
@@ -24,16 +28,46 @@ export default function App() {
const textareaRef = useRef(null); // Ref for the textarea
const [ollamaApiUrl, setOllamaApiUrl] = useState(API); // State for Ollama API URL
const [colorScheme, setColorScheme] = useState('Default'); // State for color scheme
const [streamOutput, setStreamOutput] = useState(false);
const [isSending, setIsSending] = useState(false);
const [loading, setLoading] = useState(true); // Loading state for initial session fetch
const [unreadSessions, setUnreadSessions] = useState([]); // Track unread messages
const activeRequestSessionId = useRef(null); // Ref to track the session ID of the active request
const [scrollPositions, setScrollPositions] = useState({}); // Store scroll positions for each session
// Persist userScrolledUp state per session + live ref for closures (streaming)
const [userScrolledUpState, setUserScrolledUpState] = useState({});
const userScrolledUpRef = useRef({});
// When a response arrives in a non-active chat, remember to scroll to the new ASSISTANT message on open
const [pendingScrollToLastUser, setPendingScrollToLastUser] = useState({}); // { [sessionId]: assistantMsgId }
// Live per-session scrollTop tracker to avoid races
const scrollTopsRef = useRef({});
// Tip state: { [sessionId]: messageId }
const [newMsgTip, setNewMsgTip] = useState({});
const setUserScrolledUp = React.useCallback((sessionId, value) => {
setUserScrolledUpState(prev => {
const next = { ...prev, [sessionId]: value };
userScrolledUpRef.current = next;
return next;
});
}, []);
const activeRequestSessionId = useRef(null);
const justSentMessage = useRef(false);
const lastSentSessionRef = useRef(null);
const activeSessionIdRef = useRef(activeSessionId);
useEffect(() => {
activeSessionIdRef.current = activeSessionId;
}, [activeSessionId]);
// Flag to ensure we only restore once per open of a chat
const restoredForRef = useRef(null);
// Sidebar resizing state
const [sidebarWidth, setSidebarWidth] = useState(280); // Initial sidebar width
const [sidebarWidth, setSidebarWidth] = useState(230);
const [isResizing, setIsResizing] = useState(false);
const startResizing = React.useCallback((mouseDownEvent) => {
@@ -74,6 +108,8 @@ export default function App() {
setOllamaApiUrl(settings.ollamaApiUrl);
setColorScheme(settings.colorScheme);
setModel(settings.chatModel || ''); // Load the selected model, with a fallback
setStreamOutput(settings.streamOutput || false);
setScrollPositions(settings.scrollPositions || {}); // Load scroll positions
applyColorScheme(settings.colorScheme); // Apply initial scheme
});
}, []);
@@ -171,80 +207,373 @@ export default function App() {
fetchHistory(activeSessionId);
}, [activeSessionId]);
const handleSidebarClick = (mode) => {
// Saving happens in the centralized cleanup effect below
setActiveSidebarMode(mode);
};
const handleSelectChat = (sessionId) => {
// Saving happens in the centralized cleanup effect below
selectChat(sessionId);
};
const messages = useMemo(() => {
return chatSessions.find(s => s.session_id === activeSessionId)?.messages || [];
}, [activeSessionId, chatSessions]);
// Persist the scrollTop of the session we are LEAVING (on chat change or when leaving the chat view)
useEffect(() => {
chatRef.current?.scrollTo({ top: chatRef.current.scrollHeight, behavior: 'smooth' });
}, [messages]);
const leavingSessionId = activeSessionId;
const leavingMode = activeSidebarMode;
return () => {
if (leavingMode === 'chats' && leavingSessionId) {
const top = typeof scrollTopsRef.current[leavingSessionId] === 'number'
? scrollTopsRef.current[leavingSessionId]
: (chatRef.current ? chatRef.current.scrollTop : 0);
setScrollPositions(prev => {
const updated = { ...prev, [leavingSessionId]: top };
window.electronAPI.updateSettings({ scrollPositions: updated });
return updated;
});
}
};
}, [activeSessionId, activeSidebarMode]);
// Track scroll + whether user left bottom
useEffect(() => {
const chatDiv = chatRef.current;
if (!chatDiv) return;
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = chatDiv;
const isAtBottom = (scrollHeight - scrollTop - clientHeight) <= BOTTOM_EPSILON;
if (activeSessionId) {
scrollTopsRef.current[activeSessionId] = scrollTop;
}
setUserScrolledUp(activeSessionId, !isAtBottom);
};
chatDiv.addEventListener('scroll', handleScroll);
return () => chatDiv.removeEventListener('scroll', handleScroll);
}, [activeSessionId, setUserScrolledUp]);
// Auto-hide the tip if user returns to bottom in the active chat
useEffect(() => {
const sid = activeSessionId;
if (!sid) return;
if (userScrolledUpState[sid] === false) {
setNewMsgTip(prev => {
if (!(sid in prev)) return prev;
const rest = { ...prev };
delete rest[sid];
return rest;
});
}
}, [activeSessionId, userScrolledUpState]);
// --- Robust restoration: do it before paint, exactly once per open ---
useLayoutEffect(() => {
if (activeSidebarMode !== 'chats' || !activeSessionId) return;
const div = chatRef.current;
if (!div) return;
restoredForRef.current = null;
const applyRestore = () => {
if (restoredForRef.current === activeSessionId) return;
const liveSaved = typeof scrollTopsRef.current[activeSessionId] === 'number'
? scrollTopsRef.current[activeSessionId]
: undefined;
const saved = typeof liveSaved === 'number'
? liveSaved
: scrollPositions[activeSessionId];
if (typeof saved === 'number') {
div.scrollTop = saved;
restoredForRef.current = activeSessionId;
return;
}
if (messages.length > 0) {
// default: bottom when no saved position
div.scrollTop = div.scrollHeight;
restoredForRef.current = activeSessionId;
}
};
// Run immediately (pre-paint) and also schedule a fallback rAF
applyRestore();
const r0 = requestAnimationFrame(applyRestore);
// If content size/DOM changes after first paint, apply once
const onDomChange = () => {
if (restoredForRef.current !== activeSessionId) {
requestAnimationFrame(applyRestore);
}
};
const mo = new MutationObserver(onDomChange);
mo.observe(div, { childList: true, subtree: true });
const ro = new ResizeObserver(onDomChange);
ro.observe(div);
return () => {
cancelAnimationFrame(r0);
mo.disconnect();
ro.disconnect();
};
}, [activeSessionId, activeSidebarMode, messages.length, scrollPositions]);
// If there is no saved scroll and content arrives later (e.g., on first app load),
// default to bottom exactly once for this open chat.
useEffect(() => {
if (activeSidebarMode !== 'chats' || !activeSessionId) return;
if (restoredForRef.current === activeSessionId) return; // already applied
const liveSaved = typeof scrollTopsRef.current[activeSessionId] === 'number'
? scrollTopsRef.current[activeSessionId]
: undefined;
const savedScrollTop = typeof liveSaved === 'number'
? liveSaved
: scrollPositions[activeSessionId];
// Only when there is no saved position and we now have content
if (typeof savedScrollTop !== 'number' && messages.length > 0) {
requestAnimationFrame(() => {
const div = chatRef.current;
if (!div) return;
div.scrollTop = div.scrollHeight;
restoredForRef.current = activeSessionId;
});
}
}, [messages.length, activeSessionId, activeSidebarMode, scrollPositions]);
// Session-aware scroll helpers
const scrollToBottom = (behavior = 'smooth', sessionId = null) => {
const chatDiv = chatRef.current;
if (!chatDiv) return;
const target = sessionId ?? activeSessionIdRef.current;
if (activeSessionIdRef.current !== target) return;
chatDiv.scrollTo({ top: chatDiv.scrollHeight, behavior });
setUserScrolledUp(target, false);
};
const scrollMessageToTop = (msgId, behavior = 'auto', sessionId = null) => {
const chatDiv = chatRef.current;
if (!chatDiv) return;
const target = sessionId ?? activeSessionIdRef.current;
if (activeSessionIdRef.current !== target) return;
const el = document.getElementById(msgId);
if (el) {
const top = Math.max(0, el.offsetTop - TOP_ALIGN_OFFSET);
chatDiv.scrollTo({ top, behavior });
}
};
// Handler for new message tip click
const handleNewMsgTipClick = () => {
const sid = activeSessionIdRef.current;
const msgId = newMsgTip[sid];
if (msgId) {
scrollMessageToTop(msgId, 'smooth', sid);
setNewMsgTip(prev => {
const { [sid]: _omit, ...rest } = prev;
return rest;
});
}
};
async function sendMessage() {
let targetSessionId = activeSessionId;
let isNewChat = false;
if (!input.trim() || !model) return;
let targetSessionId = activeSessionId;
let isNewChat = false;
if (!targetSessionId) {
const newSession = await createNewChat();
await new Promise(resolve => setTimeout(resolve, 200));
targetSessionId = newSession.session_id;
isNewChat = true;
} else {
// Check if it's an existing "New Chat" receiving its first message
const currentSession = chatSessions.find(s => s.session_id === targetSessionId);
if (currentSession && currentSession.name === "New Chat" && currentSession.messages.length === 0) {
isNewChat = true;
}
isNewChat = currentSession && currentSession.name === "New Chat" && currentSession.messages.length === 0;
}
const currentActiveSessionAtSend = activeSessionId; // Capture activeSessionId at send time
const userMsg = { role: 'user', content: input.trim() };
// Optimistically add user message
setChatSessions(prevSessions =>
prevSessions.map(session =>
session.session_id === targetSessionId
? { ...session, messages: [...(session.messages || []), userMsg] }
: session
)
);
setInput('');
const userMsg = { role: 'user', content: input.trim(), id: `msg-${Date.now()}-${Math.random()}` };
justSentMessage.current = true;
lastSentSessionRef.current = targetSessionId;
setUserScrolledUp(targetSessionId, false);
try {
const res = await fetch(`${ollamaApiUrl}/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: targetSessionId,
model,
message: userMsg.content
})
});
const data = await res.json();
const assistantMsg = { role: 'assistant', content: data.reply };
// Cancel any pending restore for the active session (we're about to control the scroll)
if (activeSessionIdRef.current === targetSessionId) {
restoredForRef.current = activeSessionIdRef.current; // mark as already restored
}
// Update messages with assistant's reply
// Optimistic add and flush DOM, then scroll to bottom
flushSync(() => {
setChatSessions(prevSessions =>
prevSessions.map(session =>
session.session_id === targetSessionId
? { ...session, messages: [...(session.messages || []), assistantMsg] }
? { ...session, messages: [...(session.messages || []), userMsg] }
: session
)
);
setInput('');
});
requestAnimationFrame(() => scrollToBottom('auto', targetSessionId));
setIsSending(true);
try {
if (streamOutput) {
const assistantMsgId = `msg-${Date.now()}-${Math.random()}`;
const assistantMsg = { role: 'assistant', content: '', id: assistantMsgId };
setChatSessions(prevSessions =>
prevSessions.map(session =>
session.session_id === targetSessionId
? { ...session, messages: [...(session.messages || []), assistantMsg] }
: session
)
);
(async () => {
try {
const res = await fetch(`${ollamaApiUrl}/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: targetSessionId,
model,
message: userMsg.content,
stream: true
})
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let fullReply = '';
let pendingMarked = false;
while (true) {
const { value, done } = await reader.read();
if (done) {
setChatSessions(prevSessions =>
prevSessions.map(session =>
session.session_id === targetSessionId
? {
...session,
messages: session.messages.map(m =>
m.id === assistantMsgId ? { ...m, content: fullReply } : m
)
}
: session
)
);
if (activeSessionIdRef.current === targetSessionId) {
if (!userScrolledUpRef.current[targetSessionId]) {
// user stayed at bottom -> reveal the message immediately
requestAnimationFrame(() => scrollMessageToTop(assistantMsgId, 'smooth', targetSessionId));
} else {
// user scrolled away while it was generating -> show tip instead of auto-scroll
setNewMsgTip(prev => ({ ...prev, [targetSessionId]: assistantMsgId }));
}
} else {
setPendingScrollToLastUser(prev => ({ ...prev, [targetSessionId]: assistantMsgId }));
setUnreadSessions(prev => [...new Set([...prev, targetSessionId])]);
}
break;
}
const chunk = decoder.decode(value, { stream: true });
fullReply += chunk;
const messageElement = document.getElementById(assistantMsgId)?.firstChild;
if (messageElement) {
messageElement.innerHTML = markdownToHTML(fullReply);
}
// Keep sticky-bottom *only* when streaming in the active chat and user is at/near bottom.
// This restores the old "push down while generating" behavior without fighting user scrolls.
if (
activeSessionIdRef.current === targetSessionId &&
!userScrolledUpRef.current[targetSessionId]
) {
// use 'auto' so it stays snappy during streaming
scrollToBottom('auto', targetSessionId);
}
// If streaming in a background chat, prepare a one-time guided scroll
if (activeSessionIdRef.current !== targetSessionId && !pendingMarked) {
setPendingScrollToLastUser(prev => ({ ...prev, [targetSessionId]: assistantMsgId }));
pendingMarked = true;
}
}
} catch (e) {
console.error("Failed to send message:", e);
const errorMsg = { role: 'assistant', content: 'Error: ' + e.message, id: `msg-${Date.now()}-${Math.random()}` };
setChatSessions(prevSessions =>
prevSessions.map(session =>
session.session_id === targetSessionId
? { ...session, messages: [...session.messages.slice(0, -1), errorMsg] }
: session
)
);
} finally {
setIsSending(false);
}
})();
} else {
const res = await fetch(`${ollamaApiUrl}/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: targetSessionId,
model,
message: userMsg.content,
stream: false
})
});
const data = await res.json();
const assistantMsgId = `msg-${Date.now()}`;
const assistantMsg = { role: 'assistant', content: data.reply, id: assistantMsgId };
setChatSessions(prevSessions =>
prevSessions.map(session =>
session.session_id === targetSessionId
? { ...session, messages: [...(session.messages || []), assistantMsg] }
: session
)
);
// For non-stream: align new ASSISTANT message to top, unless user scrolled away
if (assistantMsgId) {
if (activeSessionIdRef.current === targetSessionId) {
if (!userScrolledUpRef.current[targetSessionId]) {
requestAnimationFrame(() => scrollMessageToTop(assistantMsgId, 'smooth', targetSessionId));
} else {
// <<< show the tip if user scrolled away while waiting >>>
setNewMsgTip(prev => ({ ...prev, [targetSessionId]: assistantMsgId }));
}
} else {
setPendingScrollToLastUser(prev => ({ ...prev, [targetSessionId]: assistantMsgId }));
}
}
setIsSending(false);
}
// Handle unread status: only set if the chat was NOT active when the message was sent
if (activeSessionIdRef.current !== targetSessionId) {
setUnreadSessions(prev => [...new Set([...prev, targetSessionId])]);
}
// Generate title if new chat
if (isNewChat) {
fetch(`${ollamaApiUrl}/generate-title`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: targetSessionId,
message: userMsg.content
message: userMsg.content,
model: model
})
})
.then(r => r.json())
@@ -258,15 +587,15 @@ export default function App() {
}
} catch (e) {
console.error("Failed to send message:", e);
// Add error message to chat
const errorMsg = { role: 'assistant', content: 'Error: ' + e.message };
const errorMsg = { role: 'assistant', content: 'Error: ' + e.message, id: `msg-${Date.now()}-${Math.random()}` };
setChatSessions(prevSessions =>
prevSessions.map(session =>
session.session_id === targetSessionId
? { ...session, messages: [...(session.messages || []), errorMsg] }
? { ...session, messages: [...session.messages, errorMsg] }
: session
)
);
setIsSending(false);
}
}
@@ -286,7 +615,43 @@ export default function App() {
function selectChat(sessionId) {
setActiveSessionId(sessionId);
// Clear unread dot immediately for this chat
setUnreadSessions(prev => prev.filter(id => id !== sessionId));
// If we had queued a guided scroll for this chat (from background replies), run it now, smoothly
const pendingId = pendingScrollToLastUser[sessionId];
if (pendingId) {
// Defer until the chat content renders; restoration is gated by restoredForRef, so won't fight
requestAnimationFrame(() => {
let tries = 12; // ~200ms @ 60fps
const attempt = () => {
const chatDiv = chatRef.current;
if (!chatDiv) return;
let el = document.getElementById(pendingId);
if (!el) {
const sess = chatSessions.find(s => s.session_id === sessionId);
if (sess && Array.isArray(sess.messages)) {
for (let i = sess.messages.length - 1; i >= 0; i--) {
const m = sess.messages[i];
if (m.role === 'assistant' && m.id) { el = document.getElementById(m.id); break; }
}
}
}
if (el) {
scrollMessageToTop(el.id, 'smooth', sessionId);
setPendingScrollToLastUser(prev => {
const { [sessionId]: _omit, ...rest } = prev;
return rest;
});
} else if (tries-- > 0) {
requestAnimationFrame(attempt);
}
};
requestAnimationFrame(attempt);
});
}
}
function handleRename(sessionId, newName) {
@@ -334,19 +699,19 @@ export default function App() {
<div className="sidebar-header">
<div
className={`sidebar-tab ${activeSidebarMode === 'chats' ? 'active' : ''}`}
onClick={() => setActiveSidebarMode('chats')}
onClick={() => handleSidebarClick('chats')}
>
Chats
</div>
<div
className={`sidebar-tab ${activeSidebarMode === 'dbs' ? 'active' : ''}`}
onClick={() => setActiveSidebarMode('dbs')}
onClick={() => handleSidebarClick('dbs')}
>
DBs
</div>
<div
className={`sidebar-tab ${activeSidebarMode === 'settings' ? 'active' : ''}`}
onClick={() => setActiveSidebarMode('settings')}
onClick={() => handleSidebarClick('settings')}
>
Settings
</div>
@@ -358,7 +723,7 @@ export default function App() {
<div
key={session.session_id}
className={`chat-item ${session.session_id === activeSessionId ? 'active' : ''}`}
onClick={() => selectChat(session.session_id)}
onClick={() => handleSelectChat(session.session_id)}
>
{editingSessionId === session.session_id ? (
<input
@@ -434,14 +799,26 @@ export default function App() {
<strong>Chat - {chatSessions.find(s => s.session_id === activeSessionId)?.name || 'New Chat'}</strong>
</div>
<div className="chat" ref={chatRef}>
<div key={activeSessionId} className="chat" ref={chatRef}>
{messages.map((m, i) => (
<div key={i} className={'msg ' + (m.role === 'user' ? 'user' : 'assistant')}>
<div key={m.id || i} id={m.id} className={'msg ' + (m.role === 'user' ? 'user' : 'assistant')}>
<div dangerouslySetInnerHTML={{ __html: markdownToHTML(m.content) }} />
</div>
))}
</div>
{/* New message tip (active chat only) */}
{newMsgTip[activeSessionId] && (
<button
className="new-msg-tip"
onClick={handleNewMsgTipClick}
title="Jump to the new message"
aria-label="Jump to the new message"
>
New message<span style={{ marginLeft: 6 }}></span>
</button>
)}
<div className="footer">
<div className="footer-content-wrapper">
<TextareaAutosize
@@ -457,7 +834,9 @@ export default function App() {
placeholder="Ask any question..."
maxRows={13}
/>
<button className="button" onClick={sendMessage}>Send</button>
<button className="button" onClick={sendMessage} disabled={isSending}>
{isSending ? <div className="spinner"></div> : 'Send'}
</button>
</div>
</div>
</>
@@ -473,7 +852,13 @@ export default function App() {
<div className="header">
<strong>{activeSettingsSubmenu} Settings</strong>
</div>
{activeSettingsSubmenu === 'General' && <GeneralSettings />}
{activeSettingsSubmenu === 'General' && (
<GeneralSettings
onModelChange={setModel}
streamOutput={streamOutput}
onStreamOutputChange={setStreamOutput}
/>
)}
{activeSettingsSubmenu === 'Interface' && <InterfaceSettings />}
</>
)}

View File

@@ -2,17 +2,19 @@ import React, { useState, useEffect } from 'react';
const API_URL_KEY = 'ollamaApiUrl';
const MODEL_KEY = 'chatModel';
const STREAM_KEY = 'streamOutput';
export default function GeneralSettings() {
export default function GeneralSettings({ onModelChange, onStreamOutputChange }) {
const [ollamaApiUrl, setOllamaApiUrl] = useState('');
const [models, setModels] = useState([]);
const [selectedModel, setSelectedModel] = useState('');
const [streamOutput, setStreamOutput] = useState(false);
useEffect(() => {
window.electronAPI.getSettings().then(settings => {
setOllamaApiUrl(settings.ollamaApiUrl);
// Set selectedModel from settings, or fallback to an empty string if not found
setSelectedModel(settings.chatModel || '');
setStreamOutput(settings.streamOutput || false);
});
}, []);
@@ -44,6 +46,18 @@ export default function GeneralSettings() {
const newModel = e.target.value;
setSelectedModel(newModel);
window.electronAPI.setSetting(MODEL_KEY, newModel);
if (onModelChange) {
onModelChange(newModel);
}
};
const handleStreamToggle = () => {
const newStreamValue = !streamOutput;
setStreamOutput(newStreamValue);
window.electronAPI.setSetting(STREAM_KEY, newStreamValue);
if (onStreamOutputChange) {
onStreamOutputChange(newStreamValue);
}
};
return (
@@ -69,6 +83,17 @@ export default function GeneralSettings() {
{models.map(m => <option key={m} value={m}>{m}</option>)}
</select>
</div>
<div className="setting-section">
<h3>Stream Output</h3>
<label className="toggle-switch">
<input
type="checkbox"
checked={streamOutput}
onChange={handleStreamToggle}
/>
<span className="slider"></span>
</label>
</div>
</div>
);
}

View File

@@ -1,52 +1,8 @@
import React, { useState, useEffect } from 'react';
import { colorSchemes, applyColorScheme } from './colorSchemes';
const COLOR_SCHEME_KEY = 'colorScheme';
const colorSchemes = {
'Default': {
'--bg': '#0b1020',
'--panel': '#141b34',
'--text': '#e6e8ef',
'--muted': '#9aa3b2',
'--accent': '#6ea8fe',
'--border': '#24304f',
'--input-bg': '#0e1530',
'--user-msg-bg': '#111933',
'--assistant-msg-bg': '#101927',
},
'Grayscale': {
'--bg': '#1a1a1a',
'--panel': '#2a2a2a',
'--text': '#f0f0f0',
'--muted': '#aaaaaa',
'--accent': '#888888',
'--border': '#4a4a4a',
'--input-bg': '#202020',
'--user-msg-bg': '#333333',
'--assistant-msg-bg': '#252525',
},
'Rose': {
'--bg': '#200a10',
'--panel': '#301a20',
'--text': '#ffe0e0',
'--muted': '#a09090',
'--accent': '#E91E63',
'--border': '#402025',
'--input-bg': '#2a1015',
'--user-msg-bg': '#331119',
'--assistant-msg-bg': '#271019',
},
};
function applyColorScheme(schemeName) {
const scheme = colorSchemes[schemeName];
if (scheme) {
for (const [key, value] of Object.entries(scheme)) {
document.documentElement.style.setProperty(key, value);
}
}
}
export default function InterfaceSettings() {
const [selectedColorScheme, setSelectedColorScheme] = useState('Default');

78
src/colorSchemes.js Normal file
View File

@@ -0,0 +1,78 @@
const colorSchemes = {
'Nightsky': {
'--bg': '#0a0e1a',
'--panel': '#18203a',
'--text': '#ffffff',
'--muted': '#aab5c4',
'--accent': '#4a90e2',
'--border': '#304060',
'--input-bg': '#121a35',
'--user-msg-bg': '#1a2545',
'--assistant-msg-bg': '#15203a',
'--active-bg': 'rgba(74, 144, 226, 0.15)',
'--hover-bg': 'rgba(255, 255, 255, 0.05)',
},
'Grayscale': {
'--bg': '#1a1a1a',
'--panel': '#2a2a2a',
'--text': '#f0f0f0',
'--muted': '#aaaaaa',
'--accent': '#888888',
'--border': '#4a4a4a',
'--input-bg': '#202020',
'--user-msg-bg': '#333333',
'--assistant-msg-bg': '#252525',
'--active-bg': 'rgba(136, 136, 136, 0.15)',
'--hover-bg': 'rgba(255, 255, 255, 0.05)',
},
'Japan': {
'--bg': '#ffffff',
'--panel': '#f5f5f5',
'--text': '#000000',
'--muted': '#444444',
'--accent': '#e74c3c', /* Vibrant Red */
'--border': '#999999',
'--input-bg': '#ffffff',
'--user-msg-bg': '#f0f0f0',
'--assistant-msg-bg': '#f0f0f0',
'--active-bg': 'rgba(231, 76, 60, 0.15)', /* Light red for active */
'--hover-bg': 'rgba(231, 76, 60, 0.08)', /* Lighter red for hover */
},
'Lime': {
'--bg': '#f0fff0',
'--panel': '#e0ffe0',
'--text': '#1a1a1a',
'--muted': '#72a272ff',
'--accent': '#8e9f38ff',
'--border': '#a0c0a0',
'--input-bg': '#ffffff',
'--user-msg-bg': '#f8f7adff',
'--assistant-msg-bg': '#f5fff5',
'--active-bg': 'rgba(104, 159, 56, 0.2)',
'--hover-bg': 'rgba(104, 159, 56, 0.1)',
},
'Vampire': {
'--bg': '#1a050a',
'--panel': '#2a1015',
'--text': '#ffefff',
'--muted': '#c0a0a0',
'--accent': '#d81b60',
'--border': '#4a2025',
'--input-bg': '#200a10',
'--user-msg-bg': '#331119',
'--assistant-msg-bg': '#271019',
'--active-bg': 'rgba(216, 27, 96, 0.15)',
'--hover-bg': 'rgba(255, 255, 255, 0.05)',
},
};
function applyColorScheme(schemeName) {
const scheme = colorSchemes[schemeName];
if (scheme) {
for (const [key, value] of Object.entries(scheme)) {
document.documentElement.style.setProperty(key, value);
}
}
}
export { colorSchemes, applyColorScheme };

View File

@@ -1,17 +1,30 @@
import React from 'react'
import React, { useEffect } from 'react'
import { createRoot } from 'react-dom/client'
import { HashRouter as Router, Routes, Route } from 'react-router-dom'
import App from './App.jsx'
import './styles.css'
import { applyColorScheme } from './colorSchemes'
function Main() {
useEffect(() => {
window.electronAPI.getSettings().then(settings => {
if (settings.colorScheme) {
applyColorScheme(settings.colorScheme)
}
})
}, [])
return (
<React.StrictMode>
<Router>
<Routes>
<Route path="/" element={<App />} />
</Routes>
</Router>
</React.StrictMode>
)
}
const root = createRoot(document.getElementById('root'))
root.render(
<React.StrictMode>
<Router>
<Routes>
<Route path="/" element={<App />} />
</Routes>
</Router>
</React.StrictMode>
)
root.render(<Main />)

View File

@@ -11,8 +11,8 @@ export function markdownToHTML(text) {
// 2) Extract code blocks and replace with placeholders
const codeblocks = [];
const placeholder = idx => `@@CODEBLOCK${idx}@@`;
tmp = tmp.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => {
codeblocks.push({ lang, code });
tmp = tmp.replace(/```([^\n]*)\n([\s\S]*?)```/g, (_, lang, code) => {
codeblocks.push({ lang: (lang || '').trim(), code });
return placeholder(codeblocks.length - 1);
});
@@ -29,6 +29,12 @@ export function markdownToHTML(text) {
.replace(/^## (.+)$/gm, "<h2>$1</h2>")
.replace(/^# (.+)$/gm, "<h1>$1</h1>");
// 4.2) Remove blank empty lines immediately after headings
escaped = escaped.replace(
/(<h[1-4]>.*?<\/h[1-4]>)[ \t]*\n(?:[ \t]*\n)+/g,
"$1\n"
);
// 4.5) Unordered lists
escaped = escaped.replace(
/(^|\n)([ \t]*\* .+(?:\n[ \t]*\* .+)*)/g,
@@ -42,26 +48,90 @@ export function markdownToHTML(text) {
}
);
// 4.6) Markdown tables (GitHub-style). Strict: requires header, separator, ≥2 cols.
const mdTableBlockRe =
/(^\|[^\n]*\|?\s*\n\|\s*[:\-]+(?:\s*\|\s*[:\-]+)+\s*\|?\s*\n(?:\|[^\n]*\|?\s*(?:\n|$))*)/gm;
escaped = escaped.replace(mdTableBlockRe, (block) => {
// Preserve trailing newline so '^---$' can match next line
const hadTrailingNewline = /\n$/.test(block);
const lines = block.replace(/\n$/, '').split('\n');
const split = (line) => line.replace(/^\||\|$/g, '').split('|').map(s => s.trim());
const headers = split(lines[0]);
const seps = split(lines[1]);
if (headers.length < 2 || seps.length < 2) return block;
if (!seps.every(s => /^[ :\-]+$/.test(s) && /-/.test(s))) return block;
// Alignment per column (default left). Vertical-align top by default.
const aligns = seps.map(seg => {
const s = seg.replace(/\s+/g,'');
const left = s.startsWith(':');
const right = s.endsWith(':');
if (left && right) return 'center';
if (right) return 'right';
return 'left';
});
const bodyLines = lines.slice(2).filter(l => /^\|/.test(l.trim()));
const cellStyle = (i) =>
` style="text-align:${aligns[i] || 'left'};vertical-align:top;border:1px solid #e5e7eb;padding:.6rem .75rem"`;
const ths = headers.map((h,i)=>`<th${cellStyle(i)}>${h}</th>`).join('');
const rows = bodyLines.map(line => {
const cells = split(line);
const tds = cells.map((c,i)=>`<td${cellStyle(i)}>${c}</td>`).join('');
return `<tr>${tds}</tr>`;
}).join('');
// Wrapper: rounded outer border + top/bottom spacing; inner cells keep their own borders.
const wrapperOpen = `<div class="md-table" style="margin:1rem 0;border:1px solid #e5e7eb;border-radius:12px;overflow:hidden">`;
const table = `<table class="nice" style="border-collapse:separate;border-spacing:0;width:100%"><thead><tr>${ths}</tr></thead><tbody>${rows}</tbody></table></div>`;
return wrapperOpen + table + (hadTrailingNewline ? '\n' : '');
});
// 4.75) Horizontal rules
escaped = escaped.replace(/^---\s*$/gm, "<hr>");
// 5) Bold, italic, inline code
let html = escaped
.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>")
.replace(/(?<!\*)\*(.+?)\*(?!\*)/g, "<i>$1</i>")
.replace(/`(.+?)`/g, "<code>$1</code>");
// 6) Restore code blocks
// 5.5) Links
html = html.replace(/\[([^\]]+?)\]\(([^)]+?)\)/g, '<a href="$2" target="_blank"><span>$1</span> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" class="feather feather-external-link"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15 3 21 3 21 9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line></svg><span class="tooltip">$2</span></a>');
// 6) Restore code blocks with title bar (language)
html = html.replace(/@@CODEBLOCK(\d+)@@/g, (_, idx) => {
const { lang, code } = codeblocks[+idx];
const title = (lang && lang.trim()) ? lang.trim() : 'code';
const escapedCode = code.replace(/</g, "<").replace(/>/g, ">");
return `<pre><code class="language-${lang}">${escapedCode}</code></pre>`;
const head = `<div class="md-code-head" style="background:#f9fafb;border-bottom:1px solid #e5e7eb;padding:.6rem .75rem;font-weight:600">${title}</div>`;
const body = `<pre style="margin:0;padding:.75rem;border:0;overflow:auto"><code class="language-${title}">${escapedCode}</code></pre>`;
return `<div class="md-code" style="margin:1rem 0;border:1px solid #e5e7eb;border-radius:12px;overflow:hidden">${head}${body}</div>`;
});
// 7) Convert line-breaks to <br>
html = html.replace(/\n/g, "<br>");
// 8) Cleanup stray <br> immediately before/after lists
// 8) Cleanup stray <br> around lists/tables/wrappers
html = html
.replace(/<br>\s*(<ul>)/g, "$1")
.replace(/(<\/ul>)\s*<br>/g, "$1");
.replace(/(<\/ul>)\s*<br>/g, "$1")
.replace(/<br>\s*(<div class="md-table"[^>]*>)/g, "$1")
.replace(/(<\/div>)\s*<br>/g, "$1")
.replace(/<br>\s*(<div class="md-code"[^>]*>)/g, "$1")
.replace(/(<\/div>)\s*<br>/g, "$1")
.replace(/<br>\s*(<table\b[^>]*>)/g, "$1")
.replace(/(<\/table>)\s*<br>/g, "$1");
// 9) Trim spaces/tabs and remove empty newline(s) immediately after <hr>
html = html
.replace(/(<hr>)[ \t]+/g, "$1") // remove spaces/tabs
.replace(/(<hr>)(?:[ \t]*<br>)+/g, "$1"); // remove one or more blank lines (now <br>) after <hr>
return html;
}
}

View File

@@ -1,4 +1,4 @@
/* /Users/giers/Heimgeist/src/styles.css */
:root {
--bg: #0b1020;
--panel: #141b34;
@@ -19,7 +19,7 @@ body { background: var(--bg); color: var(--text); font-family: ui-sans-serif, sy
.app {
display: grid;
grid-template-columns: var(--sidebar-width, 280px) 1fr; /* Sidebar width and main content */
grid-template-columns: var(--sidebar-width, 230px) 1fr; /* Sidebar width and main content */
grid-template-rows: 1fr;
height: 100%;
}
@@ -34,6 +34,10 @@ body { background: var(--bg); color: var(--text); font-family: ui-sans-serif, sy
position: relative; /* For resizer positioning */
}
.sidebar span{
font-size: 14px;
}
.resizer {
width: 13px; /* 8px original + 5px to the right */
cursor: ew-resize;
@@ -50,28 +54,31 @@ body { background: var(--bg); color: var(--text); font-family: ui-sans-serif, sy
display: flex;
justify-content: space-around;
padding: 0; /* Remove padding to allow tabs to fill */
border-bottom: 1px solid var(--border);
background: var(--panel);
}
.sidebar-tab {
flex-grow: 1;
text-align: center;
padding: 12px 16px;
padding: 11px 16px;
cursor: pointer;
border-bottom: 3px solid transparent; /* For active indicator */
transition: background-color 0.2s ease;
}
.sidebar-tab:hover {
background-color: rgba(255, 255, 255, 0.05);
background-color: var(--hover-bg);
}
.sidebar-tab.active {
background-color: rgba(255, 255, 255, 0.1);
background-color: var(--active-bg);
border-bottom-color: var(--accent);
}
.sidebar-tab.active:hover {
background-color: var(--active-bg);
}
.sidebar-content {
flex-grow: 1;
overflow-y: auto;
@@ -94,12 +101,16 @@ body { background: var(--bg); color: var(--text); font-family: ui-sans-serif, sy
}
.settings-item.active {
background: rgba(255, 255, 255, 0.1);
background: var(--active-bg);
border-left-color: var(--accent);
}
.settings-item.active:hover {
background: var(--active-bg);
}
.settings-item:hover {
background: rgba(255, 255, 255, 0.05);
background: var(--hover-bg);
}
.settings-footer-placeholder {
@@ -122,6 +133,7 @@ body { background: var(--bg); color: var(--text); font-family: ui-sans-serif, sy
opacity: 0.9;
}
.chat-list {
overflow-y: auto;
padding: 8px 0;
@@ -188,12 +200,16 @@ body { background: var(--bg); color: var(--text); font-family: ui-sans-serif, sy
}
.chat-item.active {
background: rgba(255, 255, 255, 0.1);
background: var(--active-bg);
border-left-color: var(--accent);
}
.chat-item:hover {
background: rgba(255, 255, 255, 0.05);
background: var(--hover-bg);
}
.chat-item.active:hover {
background: var(--active-bg);
}
.rename-input {
@@ -226,7 +242,6 @@ body { background: var(--bg); color: var(--text); font-family: ui-sans-serif, sy
.sidebar-footer {
padding: 12px 16px;
border-top: 1px solid var(--border);
background: var(--panel);
}
@@ -244,6 +259,7 @@ body { background: var(--bg); color: var(--text); font-family: ui-sans-serif, sy
}
.main-content {
position: relative;
display: grid;
grid-template-rows: auto 1fr auto;
height: 100vh; /* Use viewport height to prevent window scrolling */
@@ -293,7 +309,7 @@ body { background: var(--bg); color: var(--text); font-family: ui-sans-serif, sy
padding: 12px 14px;
border-radius: 12px;
line-height: 1.5;
white-space: pre-wrap;
white-space: wrap;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Inter, Helvetica, Arial; /* Match body font */
}
@@ -307,13 +323,37 @@ textarea.input {
.msg.user {
background: var(--user-msg-bg);
margin-left: auto; /* Right-align user messages */
max-width: 800px;
max-width: 80%;
border: 1px solid var(--border);
margin-right: 5px;
}
.msg.assistant {
background: transparent;
border: none;
max-width: none; /* Remove max-width for assistant messages */
animation: fadeIn 0.3s ease-in-out;
}
/* new message pill */
.new-msg-tip {
position: absolute;
right: 24px;
bottom: 84px; /* sits just above the footer */
padding: 8px 12px;
border-radius: 9999px;
background: var(--accent);
color: var(--bg);
border: none;
cursor: pointer;
font-weight: 600;
box-shadow: 0 6px 20px rgba(0,0,0,.35);
}
.new-msg-tip:hover { opacity: .9; }
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.footer {
@@ -422,3 +462,124 @@ textarea.input {
padding: 0;
background-color: transparent;
}
.msg a {
color: var(--text); /* White color */
display: inline-flex;
align-items: center;
gap: 4px;
position: relative;
text-decoration: underline dotted;
text-underline-offset: 3px;
}
.msg a span:first-child {
}
.msg a .tooltip {
visibility: hidden;
width: auto;
background-color: var(--panel);
color: var(--text);
text-align: center;
border-radius: 6px;
padding: 5px 10px;
position: absolute;
z-index: 1;
bottom: 110%;
left: 50%;
transform: translateX(-50%);
opacity: 0;
transition: opacity 0.3s;
white-space: nowrap;
}
.msg a:hover .tooltip {
visibility: visible;
opacity: 1;
}
.msg a:hover {
color: var(--accent);
}
.msg a svg {
width: 14px;
height: 14px;
stroke: var(--text);
transition: stroke 0.2s ease;
}
.msg a:hover svg {
stroke: var(--accent);
}
.msg hr {
border: none;
border-top: 1px solid var(--border);
margin: 20px 0;
}
/* Toggle Switch Styles */
.toggle-switch {
position: relative;
display: inline-block;
width: 50px;
height: 28px;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--input-bg);
border: 1px solid var(--border);
transition: .4s;
border-radius: 28px;
}
.slider:before {
position: absolute;
content: "";
height: 20px;
width: 20px;
left: 3px;
bottom: 3px;
background-color: var(--muted);
transition: .4s;
border-radius: 50%;
}
input:checked + .slider {
background-color: var(--accent);
}
input:checked + .slider:before {
transform: translateX(22px);
background-color: var(--panel);
}
/* Spinner Styles */
.spinner {
border: 3px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top: 3px solid var(--accent);
width: 20px;
height: 20px;
animation: spin 1s linear infinite;
margin: 0 auto;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}