diff --git a/src/App.jsx b/src/App.jsx index 40ffb0a..91c4b10 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -224,12 +224,14 @@ export default function App() { if (streamOutput) { const assistantMsgId = `msg-${Date.now()}-${Math.random()}`; + // add placeholder assistant message setChatSessions(prev => prev.map(s => s.session_id === sessionId ? { ...s, messages: [...(s.messages || []), { id: assistantMsgId, role: 'assistant', content: '' }] } : s ) ); + try { const res = await fetch(`${ollamaApiUrl}/sessions/${sessionId}/regenerate`, { method: 'POST', @@ -239,17 +241,43 @@ export default function App() { const reader = res.body.getReader(); const decoder = new TextDecoder(); let full = ''; + let unreadMarked = false; // NEW + while (true) { const { value, done } = await reader.read(); if (done) break; + const chunk = decoder.decode(value, { stream: true }); full += chunk; + + // Update the growing assistant message setChatSessions(prev => prev.map(s => s.session_id === sessionId ? { ...s, messages: (s.messages || []).map(m => m.id === assistantMsgId ? { ...m, content: full } : m) } : s ) ); + + // If this session is not active while streaming, mark unread once + if (!unreadMarked && activeSessionIdRef.current !== sessionId) { + unreadMarked = true; + setPendingScrollToLastUser(prev => ({ ...prev, [sessionId]: assistantMsgId })); + setUnreadSessions(prev => [...new Set([...prev, sessionId])]); + } + } + + // On stream end: if user is in another chat, ensure unread + guided scroll are set + if (activeSessionIdRef.current !== sessionId) { + setPendingScrollToLastUser(prev => ({ ...prev, [sessionId]: assistantMsgId })); + setUnreadSessions(prev => [...new Set([...prev, sessionId])]); + } else { + // If user stayed here and didn't scroll up, align the finished answer nicely + if (!userScrolledUpRef.current[sessionId]) { + requestAnimationFrame(() => scrollMessageToTop(assistantMsgId, 'smooth', sessionId)); + } else { + // show the tip if they had scrolled away + setNewMsgTip(prev => ({ ...prev, [sessionId]: assistantMsgId })); + } } } catch (e) { console.error(e); @@ -264,12 +292,26 @@ export default function App() { body: JSON.stringify({ index, model, stream: false }) }); const data = await res.json(); + const assistantMsgId = `msg-${Date.now()}`; setChatSessions(prev => prev.map(s => s.session_id === sessionId - ? { ...s, messages: [...(s.messages || []), { role: 'assistant', content: data.reply, id: `msg-${Date.now()}` }] } + ? { ...s, messages: [...(s.messages || []), { role: 'assistant', content: data.reply, id: assistantMsgId }] } : s ) ); + + if (activeSessionIdRef.current !== sessionId) { + // reply landed in background -> mark unread + remember where to scroll + setPendingScrollToLastUser(prev => ({ ...prev, [sessionId]: assistantMsgId })); + setUnreadSessions(prev => [...new Set([...prev, sessionId])]); + } else { + // same chat -> align unless the user scrolled away + if (!userScrolledUpRef.current[sessionId]) { + requestAnimationFrame(() => scrollMessageToTop(assistantMsgId, 'smooth', sessionId)); + } else { + setNewMsgTip(prev => ({ ...prev, [sessionId]: assistantMsgId })); + } + } } catch (e) { console.error(e); } finally { @@ -349,6 +391,31 @@ export default function App() { } }, [isResizing]); + React.useEffect(() => { + const onClick = async (e) => { + const btn = e.target.closest('.codeblock__copy'); + if (!btn) return; + + const wrapper = btn.closest('.codeblock'); + const codeEl = wrapper?.querySelector('pre > code'); + if (!codeEl) return; + + try { + // Use textContent to copy the plain code accurately + await navigator.clipboard.writeText(codeEl.textContent || ''); + // Optional: brief visual feedback + btn.classList.add('copied'); + setTimeout(() => btn.classList.remove('copied'), 800); + } catch (err) { + console.error('Copy failed:', err); + } + }; + + document.addEventListener('click', onClick); + return () => document.removeEventListener('click', onClick); + }, []); + + // Load settings on startup useEffect(() => { window.electronAPI.getSettings().then(settings => { diff --git a/src/markdown.js b/src/markdown.js index 1c3c4bf..a4d630c 100644 --- a/src/markdown.js +++ b/src/markdown.js @@ -8,15 +8,20 @@ export function markdownToHTML(text) { // 1) Normalize line endings let tmp = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); - // 2) Extract code blocks and replace with placeholders + // 2) Extract code blocks and replace with placeholders (protect from all formatting) const codeblocks = []; const placeholder = idx => `@@CODEBLOCK${idx}@@`; tmp = tmp.replace(/```([^\n]*)\n([\s\S]*?)```/g, (_, lang, code) => { - codeblocks.push({ lang: (lang || '').trim(), code }); + // Strip trailing whitespace-only lines at the end of the block + let cleaned = (code || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + const lines = cleaned.split('\n'); + while (lines.length > 0 && /^\s*$/.test(lines[lines.length - 1])) lines.pop(); + cleaned = lines.join('\n'); + codeblocks.push({ lang: (lang || '').trim(), code: cleaned }); return placeholder(codeblocks.length - 1); }); - // 3) HTML-escape special characters + // 3) HTML-escape special characters (outside of code blocks) let escaped = tmp .replace(/&/g, "&") .replace(/ { - const hadTrailingNewline = /\n$/.test(block); - const lines = block.replace(/\n$/, '').split('\n'); + escaped = escaped.replace(mdTableBlockRe, (block) => { + const hadTrailingNewline = /\n$/.test(block); + const lines = block.replace(/\n$/, '').split('\n'); - const split = (line) => line.replace(/^\||\|$/g, '').split('|').map(s => s.trim()); + 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; + 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; - 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 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;padding:.6rem .75rem"`; + + const ths = headers.map((h,i)=>`
${escapedCode}`;
- return ``;
- });
-
- // 7) Convert line-breaks to | look) */ +.codeblock__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: .5rem .75rem; + background: var(--panel); + border-bottom: 1px solid var(--grid); + font-weight: 600; + font-size: 0.875rem; +} + +/* Language badge */ +.codeblock__lang { + opacity: 0.9; +} + +/* Copy button */ +.codeblock__copy { + display: inline-flex; + align-items: center; + gap: .25rem; + border: 0; + background: transparent; + padding: .25rem; + border-radius: 8px; + cursor: pointer; +} + +.codeblock__copy:hover { + background: rgba(0,0,0,0.06); +} + +.codeblock__copy.copied .icon-copy { + transform: scale(1.05); +} + +/* Pre/code styling inside wrapper (inherit your existing code styles) */ +.codeblock__pre { + margin: 0; + padding: .75rem; + overflow: auto; +} + +.codeblock__code { + display: block; + white-space: pre; +} + + /* Assistant reasoning (thoughts) UI */ .assistant-message { display: block; } .assistant-thoughts { margin-bottom: 8px; } |
|---|