diff --git a/static/app.js b/static/app.js index e14121adb..6ba1dd0bc 100644 --- a/static/app.js +++ b/static/app.js @@ -561,10 +561,21 @@ function initializeEventListeners() { // Export: PDF const exportPdfBtn = el('export-pdf-btn'); + let preparingPdf = false; if (exportPdfBtn) { - exportPdfBtn.addEventListener('click', (e) => { + exportPdfBtn.addEventListener('click', async (e) => { e.stopPropagation(); exportMenu.classList.remove('open'); + if (preparingPdf) return; + preparingPdf = true; + try { + await sessionModule.prepareHistoryForPrint(); + } catch (error) { + uiModule.showError(error?.message || 'Could not prepare the chat for PDF export.'); + return; + } finally { + preparingPdf = false; + } const meta = sessionModule.getSessions().find(s => s.id === sessionModule.getCurrentSessionId()); const sessionName = meta ? meta.name : 'Odysseus Chat'; const originalTitle = document.title; diff --git a/static/css/02-compare.css b/static/css/02-compare.css index 21fd7b555..f15c24f22 100644 --- a/static/css/02-compare.css +++ b/static/css/02-compare.css @@ -1701,6 +1701,7 @@ /* ── Print / PDF Export ── */ @media print { + html, body { height: auto !important; overflow: visible !important; } body { background: #fff !important; color: #000 !important; } #sidebar, .sidebar, #icon-rail, .hamburger-btn, #sidebar-backdrop, .chat-input-bar, .input-bar-wrapper, #welcome-screen, .chat-top-bar, .chat-meta-overlay, .msg-footer, diff --git a/static/js/sessions.js b/static/js/sessions.js index c2426412f..d12f311be 100644 --- a/static/js/sessions.js +++ b/static/js/sessions.js @@ -353,36 +353,38 @@ function _installHistoryPager(id, pageInfo, modelName) { handler: null, }; - const loadOlder = async () => { - if (!_historyPager || _historyPager.loading || _historyPager.done) return; - if (_historyPager.sessionId !== currentSessionId) return; - if (box.scrollTop > 90) return; + const loadOlder = async (force = false) => { + const pager = _historyPager; + if (!pager || pager.loading || pager.done) return; + if (pager.sessionId !== currentSessionId) return; + if (!force && box.scrollTop > 90) return; - const nextOffset = Math.max(0, _historyPager.offset - _historyPager.limit); - const nextLimit = _historyPager.offset - nextOffset; + const nextOffset = Math.max(0, pager.offset - pager.limit); + const nextLimit = pager.offset - nextOffset; if (nextLimit <= 0) { - _historyPager.done = true; + pager.done = true; return; } - _historyPager.loading = true; + pager.loading = true; const anchor = box.querySelector('.msg, .agent-thread, .gallery-bubble'); const beforeHeight = box.scrollHeight; try { - const res = await fetch(_historyUrl(_historyPager.sessionId, { limit: nextLimit, offset: nextOffset })); + const res = await fetch(_historyUrl(pager.sessionId, { limit: nextLimit, offset: nextOffset })); + if (!res.ok) throw new Error(`Chat history request failed (HTTP ${res.status})`); const data = await res.json(); - if (!_historyPager || _historyPager.sessionId !== currentSessionId) return; + if (_historyPager !== pager || pager.sessionId !== currentSessionId) return; const newEls = []; for (const msg of data.history || []) { if (msg.role !== 'user' && msg.role !== 'assistant') continue; - const els = _renderHistoryMessage(msg, _historyPager.modelName); + const els = _renderHistoryMessage(msg, pager.modelName); if (Array.isArray(els)) newEls.push(...els); } for (const el of newEls) { box.insertBefore(el, anchor || box.firstChild); } - _historyPager.offset = Number(data.offset || nextOffset); - _historyPager.done = !data.has_more_before; + pager.offset = Number(data.offset || nextOffset); + pager.done = !data.has_more_before; if (window.hljs) { newEls.forEach(el => el.querySelectorAll('pre code:not(.hljs)').forEach(block => window.hljs.highlightElement(block))); } @@ -391,16 +393,42 @@ function _installHistoryPager(id, pageInfo, modelName) { } catch (e) { console.warn('Failed to load older chat history:', e); } finally { - if (_historyPager) _historyPager.loading = false; + pager.loading = false; } }; + _historyPager.loadOlder = loadOlder; _historyPager.handler = () => { - if (box.scrollTop <= 90) loadOlder(); + if (box.scrollTop <= 90 && _historyPager && !_historyPager.loading) { + _historyPager.pending = loadOlder(); + } }; box.addEventListener('scroll', _historyPager.handler, { passive: true }); } +export async function prepareHistoryForPrint() { + const pager = _historyPager; + if (!pager) return; + const ensureCurrentChat = () => { + if (_historyPager !== pager || currentSessionId !== pager.sessionId) { + throw new Error('Chat changed while preparing the PDF. Export again from the intended chat.'); + } + }; + while (!pager.done) { + await pager.pending; + ensureCurrentChat(); + if (pager.done) break; + const previousOffset = pager.offset; + pager.pending = pager.loadOlder(true); + await pager.pending; + ensureCurrentChat(); + if (!pager.done && pager.offset >= previousOffset) { + throw new Error('Could not load the complete chat for PDF export. Please try again.'); + } + } + ensureCurrentChat(); +} + function _getIncognitoIds() { try { return JSON.parse(sessionStorage.getItem(_INCOGNITO_SESSIONS_KEY) || '[]'); } catch { return []; } } @@ -4061,6 +4089,7 @@ const sessionModule = { renderSessionList, loadSessions, selectSession, + prepareHistoryForPrint, createDirectChat, materializePendingSession, preMaterializePendingSession, diff --git a/tests/test_chat_print_pagination.py b/tests/test_chat_print_pagination.py new file mode 100644 index 000000000..35858933a --- /dev/null +++ b/tests/test_chat_print_pagination.py @@ -0,0 +1,61 @@ +"""The shipped CSS must print every message beyond the screen viewport.""" + +import json +import os +import shutil +import subprocess +from pathlib import Path + +import pytest +from pypdf import PdfReader + +ROOT = Path(__file__).resolve().parents[1] + + +@pytest.mark.skipif(not shutil.which("node"), reason="node is required for Chromium printing") +def test_chat_print_preserves_the_complete_transcript(tmp_path): + script = r''' + import fs from 'node:fs'; + import path from 'node:path'; + import { chromium } from 'playwright'; + const origin = process.env.ODYSSEUS_TEST_STATIC_ORIGIN; + const source = fs.readFileSync('static/index.html', 'utf8'); + const links = [...source.matchAll(/]*rel="stylesheet"[^>]*>/g)] + .map(match => match[0]).join('\n'); + const messages = Array.from({length: 24}, (_, index) => { + const marker = 'EXPORTSENTINEL' + String(index + 1).padStart(2, '0'); + const paragraphs = Array.from({length:4}, () => + '
This synthetic message must survive printing beyond the screen viewport. ' + + 'Every paragraph belongs to the saved conversation.
').join(''); + return `${marker}
${paragraphs}