Commit 9868b282 by Michael Pastushkov

fix

parent 7bd1e40b
...@@ -11,12 +11,51 @@ ...@@ -11,12 +11,51 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background-color: #f8f9fa; background-color: #f8f9fa;
margin: 0;
} }
main { flex: 1 1 auto; overflow-y: auto; position: relative; }
header { position: sticky; top: 0; z-index: 1020; }
.composer { position: sticky; bottom: 0; z-index: 1020; background: #fff; border-top: 1px solid #dee2e6; }
footer { font-size: .75rem; color: #6c757d; text-align: center; padding: .5rem; border-top: 1px solid #dee2e6; background: #fdfdfd; }
/* Make only main scrollable, with space for header+composer+footer */
main {
flex: 1 1 auto;
overflow-y: auto;
position: relative;
padding: 5rem 0 8rem; /* top space for header, bottom space for composer+footer */
}
/* Header fixed at top */
header {
position: fixed;
top: 0;
left: 0;
width: 100%;
z-index: 1020;
}
/* Composer fixed above footer */
.composer {
position: fixed;
bottom: 2rem; /* leave space for footer */
left: 0;
width: 100%;
z-index: 1020;
background: #fff;
border-top: 1px solid #dee2e6;
}
/* Footer fixed at very bottom */
footer {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
z-index: 1020;
font-size: .75rem;
color: #6c757d;
text-align: center;
padding: .5rem;
border-top: 1px solid #dee2e6;
background: #fdfdfd;
}
.cat-spinner { width: 36px; height: 36px; object-fit: contain; filter: drop-shadow(0 1px 2px rgba(0,0,0,.35)); } .cat-spinner { width: 36px; height: 36px; object-fit: contain; filter: drop-shadow(0 1px 2px rgba(0,0,0,.35)); }
.thinking-cat { width: 64px; height: 64px; object-fit: contain; display:block; margin:.25rem auto; } .thinking-cat { width: 64px; height: 64px; object-fit: contain; display:block; margin:.25rem auto; }
.message { .message {
...@@ -52,6 +91,35 @@ ...@@ -52,6 +91,35 @@
filter: drop-shadow(0 6px 16px rgba(0,0,0,.15)); filter: drop-shadow(0 6px 16px rgba(0,0,0,.15));
opacity: .98; opacity: .98;
} }
/* --- Floating Scroll Button --- */
.scroll-fab {
position: fixed;
right: 24px;
bottom: 96px; /* sits above the sticky composer */
width: 48px;
height: 48px;
border-radius: 50%;
border: none;
background: #0d6efd;
color: #fff;
display: none; /* toggled via JS */
align-items: center;
justify-content: center;
box-shadow: 0 8px 18px rgba(13,110,253,.35);
cursor: pointer;
z-index: 1030;
}
.scroll-fab:focus { outline: none; box-shadow: 0 0 0 0.25rem rgba(13,110,253,.25); }
.scroll-fab svg { width: 22px; height: 22px; pointer-events: none; }
.scroll-fab.pulse {
animation: scrollPulse 1.5s ease-in-out infinite;
}
@keyframes scrollPulse {
0% { transform: translateY(0); }
50% { transform: translateY(2px); }
100% { transform: translateY(0); }
}
</style> </style>
</head> </head>
<body> <body>
...@@ -73,6 +141,14 @@ ...@@ -73,6 +141,14 @@
<div id="thread"></div> <div id="thread"></div>
</main> </main>
<!-- Floating Scroll-to-Bottom Button -->
<button id="scrollFab" class="scroll-fab" type="button" aria-label="">
<!-- Down arrow icon -->
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 16a1 1 0 0 1-.7-.29l-7-7a1 1 0 1 1 1.4-1.42L12 13.59l6.3-6.3a1 1 0 0 1 1.4 1.42l-7 7A1 1 0 0 1 12 16z"/>
</svg>
</button>
<!-- Composer --> <!-- Composer -->
<div class="composer py-3"> <div class="composer py-3">
<div class="container"> <div class="container">
...@@ -87,9 +163,7 @@ ...@@ -87,9 +163,7 @@
</div> </div>
<!-- Disclaimer Footer --> <!-- Disclaimer Footer -->
<footer id="disclaimer"> <footer id="disclaimer"></footer>
<!-- text filled by i18n -->
</footer>
<script src="https://cdn.jsdelivr.net/npm/marked@12.0.2/marked.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/marked@12.0.2/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.1.6/dist/purify.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/dompurify@3.1.6/dist/purify.min.js"></script>
...@@ -160,6 +234,11 @@ ...@@ -160,6 +234,11 @@
if (stopBtn) stopBtn.textContent = t('buttons.stop','Stop'); if (stopBtn) stopBtn.textContent = t('buttons.stop','Stop');
const disclaimer = document.getElementById('disclaimer'); const disclaimer = document.getElementById('disclaimer');
if (disclaimer) disclaimer.textContent = t('footer.disclaimer','Beware: Cat can make mistakes.'); if (disclaimer) disclaimer.textContent = t('footer.disclaimer','Beware: Cat can make mistakes.');
const scrollFab = document.getElementById('scrollFab');
if (scrollFab) {
scrollFab.setAttribute('aria-label', t('buttons.scrollDown','Scroll to latest'));
scrollFab.title = t('buttons.scrollDown','Scroll to latest');
}
setWelcomeImage(); setWelcomeImage();
} }
...@@ -173,18 +252,43 @@ ...@@ -173,18 +252,43 @@
const goBtn = document.getElementById('goBtn'); const goBtn = document.getElementById('goBtn');
const stopBtn = document.getElementById('stopBtn'); const stopBtn = document.getElementById('stopBtn');
const welcomeLogo = document.getElementById('welcomeLogo'); const welcomeLogo = document.getElementById('welcomeLogo');
const mainScroll = document.getElementById('mainScroll');
const scrollFab = document.getElementById('scrollFab');
let firstRequestDone = false; let firstRequestDone = false;
let controller = null; let controller = null;
// --- Floating Scroll Button logic ---
const BOTTOM_EPS = 32; // pixels from bottom considered "at bottom"
function isOverflowing() {
return mainScroll.scrollHeight - mainScroll.clientHeight > 1;
}
function isNearBottom() {
return (mainScroll.scrollHeight - mainScroll.clientHeight - mainScroll.scrollTop) <= BOTTOM_EPS;
}
function updateScrollFabVisibility() {
if (isOverflowing() && !isNearBottom()) {
scrollFab.style.display = 'flex';
} else {
scrollFab.style.display = 'none';
scrollFab.classList.remove('pulse');
}
}
function scrollToBottom(smooth = true) {
mainScroll.scrollTo({ top: mainScroll.scrollHeight, behavior: smooth ? 'smooth' : 'auto' });
}
scrollFab.addEventListener('click', () => scrollToBottom(true));
mainScroll.addEventListener('scroll', updateScrollFabVisibility);
window.addEventListener('resize', updateScrollFabVisibility);
window.addEventListener('DOMContentLoaded', async () => { window.addEventListener('DOMContentLoaded', async () => {
try { try {
await fetch('/api/clear', { method: 'GET' }); await fetch('/api/clear', { method: 'GET' });
} catch (err) { } catch (err) { /* ignore */ }
// nothing to do
}
await loadLocale(CURRENT_LOCALE); await loadLocale(CURRENT_LOCALE);
inputText.focus(); inputText.focus();
updateScrollFabVisibility();
}); });
function renderMarkdown(targetEl, markdownText) { function renderMarkdown(targetEl, markdownText) {
...@@ -233,6 +337,10 @@ ...@@ -233,6 +337,10 @@
inputText.value = ''; inputText.value = '';
controller = new AbortController(); controller = new AbortController();
setComposerLocked(true); setComposerLocked(true);
// ensure we evaluate FAB after new nodes
updateScrollFabVisibility();
try { try {
const res = await fetch(ENDPOINT, { const res = await fetch(ENDPOINT, {
method: 'POST', method: 'POST',
...@@ -246,6 +354,7 @@ ...@@ -246,6 +354,7 @@
let rawMarkdown = ''; let rawMarkdown = '';
let receivedAny = false; let receivedAny = false;
let rafPending = false; let rafPending = false;
const flush = () => { const flush = () => {
if (rafPending) return; if (rafPending) return;
rafPending = true; rafPending = true;
...@@ -253,13 +362,25 @@ ...@@ -253,13 +362,25 @@
rafPending = false; rafPending = false;
if (!receivedAny) { clearThinkingCatIfPresent(assistantMsg); receivedAny = true; } if (!receivedAny) { clearThinkingCatIfPresent(assistantMsg); receivedAny = true; }
renderMarkdown(assistantMsg, rawMarkdown); renderMarkdown(assistantMsg, rawMarkdown);
// Auto-scroll only if user is already near the bottom.
// If not, show FAB and pulse to indicate new content.
if (isNearBottom()) {
scrollToBottom(true);
} else {
scrollFab.style.display = 'flex';
scrollFab.classList.add('pulse');
}
updateScrollFabVisibility();
}); });
}; };
for (;;) { for (;;) {
const { value, done } = await reader.read(); const { value, done } = await reader.read();
if (done) break; if (done) break;
const chunk = decoder.decode(value, { stream:true }); const chunk = decoder.decode(value, { stream:true });
for (const ch of chunk) { rawMarkdown += ch; flush(); } for (const ch of chunk) { rawMarkdown += ch; }
flush();
} }
flush(); flush();
} catch (err) { } catch (err) {
...@@ -275,6 +396,7 @@ ...@@ -275,6 +396,7 @@
} finally { } finally {
setComposerLocked(false); setComposerLocked(false);
controller = null; controller = null;
updateScrollFabVisibility();
} }
} }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment