agent terminal: sticky-bottom auto-scroll with new-row pill

new rows no longer yank the view if the operator is scrolled up.
threshold for 'near bottom' is 48px. when not near bottom, an amber
'↓ N new' pill appears in the bottom-right of the terminal-wrap;
clicking it jumps to bottom. scrolling back near bottom clears the
counter. backfilled (history-replay) rows always scroll to bottom
since the operator hasn't started reading yet.
This commit is contained in:
müde 2026-05-15 19:30:34 +02:00
parent 875a8f5be4
commit 08f2ec5232
2 changed files with 78 additions and 2 deletions

View file

@ -130,6 +130,7 @@ pre.diff {
so anything that bleeds through (page banner glow, scroll position) so anything that bleeds through (page banner glow, scroll position)
reads as out-of-focus depth instead of sharp competing detail. */ reads as out-of-focus depth instead of sharp competing detail. */
.terminal-wrap { .terminal-wrap {
position: relative;
background: rgba(17, 17, 27, 0.78); background: rgba(17, 17, 27, 0.78);
-webkit-backdrop-filter: blur(8px) saturate(120%); -webkit-backdrop-filter: blur(8px) saturate(120%);
backdrop-filter: blur(8px) saturate(120%); backdrop-filter: blur(8px) saturate(120%);
@ -227,6 +228,36 @@ pre.diff {
from { opacity: 0; transform: translateY(4px); } from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); } to { opacity: 1; transform: translateY(0); }
} }
/* "↓ N new" pill: shown when new rows arrive while the operator is
scrolled up; click to jump to bottom. */
.tail-pill {
position: absolute;
right: 1em;
bottom: 4.2em;
background: var(--amber);
color: #11111b;
font-family: inherit;
font-size: 0.8em;
font-weight: bold;
letter-spacing: 0.08em;
border: 0;
border-radius: 999px;
padding: 0.35em 0.9em;
cursor: pointer;
box-shadow: 0 0 14px -2px rgba(250, 179, 135, 0.85);
opacity: 0;
transform: translateY(6px);
pointer-events: none;
transition: opacity 160ms ease, transform 160ms ease;
}
.tail-pill.visible {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.tail-pill:hover {
filter: brightness(1.1);
}
details.row { details.row {
white-space: normal; white-space: normal;
padding-left: 0.5em; padding-left: 0.5em;

View file

@ -331,13 +331,58 @@
row: (cls, text) => row(cls, text), row: (cls, text) => row(cls, text),
clear: () => { log.innerHTML = ''; placeholder = null; }, clear: () => { log.innerHTML = ''; placeholder = null; },
}; };
// Sticky-bottom auto-scroll. If the user is reading scrolled-up, new
// rows do NOT yank the view. A floating "↓ N new" pill appears in
// the bottom-right corner; clicking it jumps to bottom and clears
// the counter. Scrolling back near the bottom also clears it.
const NEAR_BOTTOM_PX = 48;
let unseen = 0;
let pill = null;
function isNearBottom() {
return log.scrollHeight - log.scrollTop - log.clientHeight <= NEAR_BOTTOM_PX;
}
function ensurePill() {
if (pill) return pill;
pill = document.createElement('button');
pill.type = 'button';
pill.className = 'tail-pill';
pill.addEventListener('click', () => {
log.scrollTop = log.scrollHeight;
});
log.parentElement.appendChild(pill);
return pill;
}
function updatePill() {
if (unseen <= 0) {
if (pill) pill.classList.remove('visible');
return;
}
ensurePill();
pill.textContent = '↓ ' + unseen + ' new';
pill.classList.add('visible');
}
log.addEventListener('scroll', () => {
if (isNearBottom()) {
unseen = 0;
updatePill();
}
});
function afterAppend() {
if (currentNoAnim || isNearBottom()) {
log.scrollTop = log.scrollHeight;
} else {
unseen += 1;
updatePill();
}
}
function row(cls, text) { function row(cls, text) {
clearPlaceholder(); clearPlaceholder();
const e = document.createElement('div'); const e = document.createElement('div');
e.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : ''); e.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : '');
e.textContent = text; e.textContent = text;
log.appendChild(e); log.appendChild(e);
log.scrollTop = log.scrollHeight; afterAppend();
return e; return e;
} }
function details(cls, summary, body) { function details(cls, summary, body) {
@ -352,7 +397,7 @@
pre.textContent = body; pre.textContent = body;
d.appendChild(pre); d.appendChild(pre);
log.appendChild(d); log.appendChild(d);
log.scrollTop = log.scrollHeight; afterAppend();
return d; return d;
} }
function trim(s, n) { return s.length > n ? s.slice(0, n) + '…' : s; } function trim(s, n) { return s.length > n ? s.slice(0, n) + '…' : s; }