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)=>`${h}`).join(''); + const rows = bodyLines.map(line => { + const cells = split(line); + const tds = cells.map((c,i)=>`${c}`).join(''); + return `${tds}`; + }).join(''); + + const table = `${ths}${rows}
`; + + return table + (hadTrailingNewline ? '\n' : ''); }); - 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)=>`${h}`).join(''); - const rows = bodyLines.map(line => { - const cells = split(line); - const tds = cells.map((c,i)=>`${c}`).join(''); - return `${tds}`; - }).join(''); - - const table = `${ths}${rows}
`; - - return table + (hadTrailingNewline ? '\n' : ''); -}); - // 4.75) Horizontal rules escaped = escaped.replace(/^---\s*$/gm, "
"); - // 5) Bold, italic, inline code + // 5) Bold, italic, inline code (inline code only; fenced were extracted) let html = escaped .replace(/\*\*(.+?)\*\*/g, "$1") .replace(/(?$1") @@ -112,38 +117,51 @@ escaped = escaped.replace(mdTableBlockRe, (block) => { // 5.5) Links html = html.replace(/\[([^\]]+?)\]\(([^)]+?)\)/g, '$1 $2'); - // 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, ">"); - const head = `
${title}
`; - const body = `
${escapedCode}
`; - return `
${head}${body}
`; - }); - - // 7) Convert line-breaks to
+ // 6) Convert line-breaks to
for NON-code content (code is still placeholdered) html = html.replace(/\n/g, "
"); - // 8) Cleanup stray
around lists/tables/wrappers + // 6.1) Cleanup stray
around lists/tables/wrappers that already exist html = html .replace(/
\s*(
    )/g, "$1") .replace(/(<\/ul>)\s*
    /g, "$1") .replace(/
    \s*(
    ]*>)/g, "$1") .replace(/(<\/div>)\s*
    /g, "$1") - .replace(/
    \s*(
    ]*>)/g, "$1") - .replace(/(<\/div>)\s*
    /g, "$1") .replace(/
    \s*(]*>)/g, "$1") .replace(/(<\/table>)\s*
    /g, "$1") - .replace(/
    \s*(
    )/g, "$1") // New: Cleanup
    before blockquote - .replace(/(<\/blockquote>)\s*
    /g, "$1"); // New: Cleanup
    after blockquote + .replace(/
    \s*(
    )/g, "$1") + .replace(/(<\/blockquote>)\s*
    /g, "$1"); - // 9) Trim spaces/tabs and remove empty newline(s) immediately after
    , blockquote, and ul + // 6.2) Trim spaces/tabs and remove empty newline(s) after
    , blockquote, ul html = html - .replace(/(
    )[ \t]+/g, "$1") // remove spaces/tabs - .replace(/(
    )(?:[ \t]*
    )+/g, "$1") // remove one or more blank lines (now
    ) after
    - .replace(/(<\/blockquote>)(?:[ \t]*
    )+/g, "$1") // New: Remove empty lines after blockquote - .replace(/(<\/ul>)(?:[ \t]*
    )+/g, "$1"); // New: Remove empty lines after ul + .replace(/(
    )[ \t]+/g, "$1") + .replace(/(
    )(?:[ \t]*
    )+/g, "$1") + .replace(/(<\/blockquote>)(?:[ \t]*
    )+/g, "$1") + .replace(/(<\/ul>)(?:[ \t]*
    )+/g, "$1"); + + // 7) Restore code blocks with title bar (language) + copy button (no inline handlers) + html = html.replace(/@@CODEBLOCK(\d+)@@/g, (_, idx) => { + const { lang, code } = codeblocks[+idx]; + const title = (lang && lang.trim()) ? lang.trim() : 'code'; + + // Escape only for HTML rendering inside ; keep raw \n (no
    here!) + const escapedCode = code + .replace(/&/g, "&") + .replace(//g, ">"); + + // Single-line header to avoid global
    interference + const head = `
    ${title}
    `; + + // Ensure wrapping inside container and preserve newlines for copy/paste + const body = `
    ${escapedCode}
    `; + + return `
    ${head}${body}
    `; + }); + + // 8) Final cleanup around codeblocks specifically (remove stray
    added next to placeholders) + html = html + .replace(/
    \s*(?=
    before opening + .replace(/(
    ]*>[\s\S]*?<\/div>)\s*
    /g, "$1"); //
    right after closing return html; -} +} \ No newline at end of file diff --git a/src/styles.css b/src/styles.css index c6365ca..1c4e0b6 100644 --- a/src/styles.css +++ b/src/styles.css @@ -699,6 +699,64 @@ input:checked + .slider:before { } +/* Codeblock wrapper */ +.codeblock { + border: 1px solid var(--grid, #e5e7eb); + border-radius: 12px; + overflow: hidden; + margin: 1rem 0; +} + +/* Header bar (mirrors your 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; }