feat(ui): jump-to-latest pill when scrolled up in chat

When the user scrolls away from the bottom of a conversation, a floating
"Jump to latest" pill appears (sticky, centered above the composer); clicking
it smooth-scrolls to the newest message and re-pins auto-scroll. Resets on
chat switch. Adds the chat.actions.jumpToLatest i18n key to all locales.

Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto
2026-06-18 17:26:06 +00:00
parent 5510262aab
commit 8991b94a80
9 changed files with 52 additions and 7 deletions

View File

@@ -71,7 +71,8 @@
},
"actions": {
"copy": "Kopieren",
"regenerate": "Neu generieren"
"regenerate": "Neu generieren",
"jumpToLatest": "Jump to latest"
},
"streaming": {
"transferring": "Modell wird übertragen...",

View File

@@ -71,7 +71,8 @@
},
"actions": {
"copy": "Copy",
"regenerate": "Regenerate"
"regenerate": "Regenerate",
"jumpToLatest": "Jump to latest"
},
"streaming": {
"transferring": "Transferring model...",

View File

@@ -71,7 +71,8 @@
},
"actions": {
"copy": "Copiar",
"regenerate": "Regenerar"
"regenerate": "Regenerar",
"jumpToLatest": "Jump to latest"
},
"streaming": {
"transferring": "Transfiriendo modelo...",

View File

@@ -71,7 +71,8 @@
},
"actions": {
"copy": "Salin",
"regenerate": "Hasilkan ulang"
"regenerate": "Hasilkan ulang",
"jumpToLatest": "Jump to latest"
},
"streaming": {
"transferring": "Mentransfer model...",

View File

@@ -71,7 +71,8 @@
},
"actions": {
"copy": "Copia",
"regenerate": "Rigenera"
"regenerate": "Rigenera",
"jumpToLatest": "Torna in fondo"
},
"streaming": {
"transferring": "Trasferimento del modello...",

View File

@@ -71,7 +71,8 @@
},
"actions": {
"copy": "복사",
"regenerate": "다시 생성"
"regenerate": "다시 생성",
"jumpToLatest": "Jump to latest"
},
"streaming": {
"transferring": "모델 전송 중...",

View File

@@ -71,7 +71,8 @@
},
"actions": {
"copy": "复制",
"regenerate": "重新生成"
"regenerate": "重新生成",
"jumpToLatest": "Jump to latest"
},
"streaming": {
"transferring": "正在传输模型...",

View File

@@ -3090,6 +3090,28 @@ select.input {
}
.chat-messages::-webkit-scrollbar { width: 6px; }
.chat-messages::-webkit-scrollbar-track { background: transparent; }
/* Floating "jump to latest" pill, shown when scrolled away from the bottom. */
.chat-jump-latest {
position: sticky;
bottom: 12px;
align-self: center;
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
padding: var(--spacing-xs) var(--spacing-md);
background: var(--color-surface-raised);
color: var(--color-text-primary);
border: 1px solid var(--color-border-default);
border-radius: var(--radius-full);
box-shadow: var(--shadow-md);
font-size: var(--text-sm);
font-weight: var(--font-weight-medium);
cursor: pointer;
z-index: 5;
animation: pageReveal var(--duration-fast) var(--ease-default) both;
}
.chat-jump-latest:hover { border-color: var(--color-primary-border); color: var(--color-primary); }
.chat-messages::-webkit-scrollbar-thumb {
background: var(--color-border-subtle);
border-radius: 3px;

View File

@@ -336,6 +336,7 @@ export default function Chat() {
const messagesRef = useRef(null)
const textareaRef = useRef(null)
const stickToBottomRef = useRef(true)
const [scrolledUp, setScrolledUp] = useState(false)
const chatsMenuRef = useRef(null)
// Focus mode: once a conversation has at least one message we slim the
@@ -601,6 +602,7 @@ export default function Chat() {
const onScroll = () => {
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight
stickToBottomRef.current = distanceFromBottom < 80
setScrolledUp(distanceFromBottom > 160)
}
el.addEventListener('scroll', onScroll, { passive: true })
return () => el.removeEventListener('scroll', onScroll)
@@ -616,6 +618,7 @@ export default function Chat() {
// user's focus-mode override — each chat starts fresh.
useEffect(() => {
stickToBottomRef.current = true
setScrolledUp(false)
messagesEndRef.current?.scrollIntoView({ behavior: 'auto' })
setFocusOverride(false)
}, [activeChat?.id])
@@ -1235,6 +1238,19 @@ export default function Chat() {
</div>
)}
<div ref={messagesEndRef} />
{scrolledUp && (
<button
type="button"
className="chat-jump-latest"
onClick={() => {
stickToBottomRef.current = true
setScrolledUp(false)
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}}
>
<i className="fas fa-arrow-down" aria-hidden="true" /> {t('actions.jumpToLatest')}
</button>
)}
</div>
{/* Token info bar */}