feat(chat): floating back-to-bottom button with End-key shortcut

Add a floating back-to-bottom control to the chat message list that
appears when the user scrolls up away from the live bottom. The button
auto-docks to the right edge after 15s of inactivity (with a subtle
breathing pulse) and un-docks on mouseenter, keeping it unobtrusive
while reading history.

- End key jumps to the bottom, ignored when focus is in an input,
  textarea, or contentEditable field.
- Explicit jump (button click or End) forces past the stick-to-bottom
  escape lock and clears it so sticky auto-scroll resumes following new
  content; automatic scrolls still respect the escape lock so they do
  not fight the user reading history.
- Larger thumb-reach hit area and lower placement on mobile.
- New i18n key chat.scrollToBottom (zh-CN / en-US).
This commit is contained in:
MIST 2026-06-25 09:44:43 +08:00 committed by matevip
parent 1d1c35aadf
commit f7f1c30557
3 changed files with 179 additions and 5 deletions

View File

@ -88,13 +88,33 @@
</slot>
</div>
</div>
<!-- Floating "back to bottom" button: appears when the user scrolls up to
read history, auto-docks to the edge after 15s of inactivity. -->
<Transition name="fade-up">
<div
v-if="!isAtBottom"
class="scroll-to-bottom-wrapper"
:class="{ docked: isButtonDocked }"
@mouseenter="undockButton"
>
<button
class="scroll-to-bottom"
type="button"
:title="$t('chat.scrollToBottom')"
@click="goToBottom"
>
<el-icon><ArrowDown /></el-icon>
</button>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { computed, watch, nextTick } from 'vue'
import { computed, ref, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { ChatDotRound, DataLine, EditPen, Monitor, Right } from '@element-plus/icons-vue'
import { ArrowDown, ChatDotRound, DataLine, EditPen, Monitor, Right } from '@element-plus/icons-vue'
const { t } = useI18n()
import MessageBubble from './MessageBubble.vue'
@ -166,7 +186,7 @@ const isCronHeader = (msg: Message) => {
}
//
const { scrollRef, contentRef, isAtBottom, scrollToBottom } = useStickToBottom({
const { scrollRef, contentRef, isAtBottom, escapedFromLock, scrollToBottom } = useStickToBottom({
enabled: props.autoScroll,
offset: 70,
smooth: true,
@ -185,6 +205,8 @@ watch(
() => props.messages,
async () => {
await nextTick()
// Don't auto-scroll while the user has scrolled up to read history.
if (escapedFromLock.value) return
scrollToBottom()
},
{ deep: true }
@ -200,6 +222,63 @@ watch(
}
}
)
// Explicit "jump to bottom" request (button click / End key): force past the
// escape lock and clear it so sticky auto-scroll resumes following new content.
function goToBottom() {
escapedFromLock.value = false
scrollToBottom({ force: true, smooth: true })
}
// ========== Back-to-bottom button: auto-dock to the edge after 15s idle ==========
const DOCK_DELAY_MS = 15000
let dockTimer: ReturnType<typeof setTimeout> | null = null
const isButtonDocked = ref(false)
function clearDockTimer() {
if (dockTimer) { clearTimeout(dockTimer); dockTimer = null }
}
function resetDockTimer() {
isButtonDocked.value = false
if (!isAtBottom.value) {
clearDockTimer()
dockTimer = setTimeout(() => { isButtonDocked.value = true }, DOCK_DELAY_MS)
}
}
function undockButton() {
isButtonDocked.value = false
resetDockTimer()
}
watch(isAtBottom, (atBottom) => {
if (atBottom) { clearDockTimer(); isButtonDocked.value = false }
else resetDockTimer()
})
watch(scrollRef, (el, _, onCleanup) => {
if (!el) return
el.addEventListener('scroll', resetDockTimer, { passive: true })
onCleanup(() => el.removeEventListener('scroll', resetDockTimer))
})
// ========== End key jumps to the bottom ==========
function handleGlobalKeydown(e: KeyboardEvent) {
if (e.key !== 'End') return
// Only respond when focus is not in an editable field (textarea / input / contentEditable).
const el = document.activeElement
if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || (el as HTMLElement).isContentEditable)) return
if (isAtBottom.value) return
e.preventDefault()
goToBottom()
}
onMounted(() => document.addEventListener('keydown', handleGlobalKeydown))
onUnmounted(() => {
clearDockTimer()
document.removeEventListener('keydown', handleGlobalKeydown)
})
</script>
<style scoped>
@ -396,10 +475,88 @@ watch(
}
}
/* 滚动提示 */
.is-scrolling .scroll-to-bottom {
/* Back-to-bottom floating button */
.scroll-to-bottom-wrapper {
position: absolute;
bottom: 20%;
right: 0;
z-index: 10;
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: flex-end;
padding: 6px 8px;
}
.scroll-to-bottom {
width: 36px;
height: 36px;
border-radius: 50%;
border: 1px solid var(--mc-border, #e2e8f0);
background: var(--mc-bg-elevated, rgba(255, 255, 255, 0.92));
backdrop-filter: blur(6px);
color: var(--mc-text-secondary, #64748b);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
opacity: 1;
pointer-events: auto;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
transform: translateX(0);
transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.35s ease,
box-shadow 0.2s ease,
background 0.2s ease;
flex-shrink: 0;
}
.scroll-to-bottom:hover {
background: var(--mc-panel-raised, #f1f5f9);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
transform: scale(1.08);
color: var(--mc-primary, #D97757);
border-color: var(--mc-primary, #D97757);
}
/* Click feedback: shrink slightly on press, then spring back. */
.scroll-to-bottom:active {
transform: scale(0.88);
transition: transform 0.1s ease;
}
/* Docked to the edge: only ~8px stays visible. */
.scroll-to-bottom-wrapper.docked .scroll-to-bottom {
transform: translateX(28px);
opacity: 0.45;
/* Subtle breathing pulse to remind the user the button is still there. */
animation: dock-breathe 3s ease-in-out infinite;
}
@keyframes dock-breathe {
0%, 100% { box-shadow: 0 0 0 0 rgba(217, 119, 87, 0); }
50% { box-shadow: 0 0 8px 2px rgba(217, 119, 87, 0.18); }
}
/* Restore as soon as the pointer approaches. */
.scroll-to-bottom-wrapper.docked:hover .scroll-to-bottom {
transform: translateX(0);
opacity: 1;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
animation: none;
}
/* Transition: fade + slide up */
.fade-up-enter-active,
.fade-up-leave-active {
transition: opacity 0.25s ease, transform 0.25s ease;
}
.fade-up-enter-from,
.fade-up-leave-to {
opacity: 0;
transform: translateY(16px);
}
/* ===== 移动端适配 ===== */
@ -416,6 +573,21 @@ watch(
min-height: 240px;
}
/* Mobile: larger button positioned lower for the thumb reach zone. */
.scroll-to-bottom-wrapper {
bottom: 12%;
}
.scroll-to-bottom {
width: 44px;
height: 44px;
font-size: 18px;
}
.scroll-to-bottom-wrapper.docked .scroll-to-bottom {
transform: translateX(36px); /* 44px - 8px = 36px */
}
.welcome-screen {
padding: 16px 10px;
}

View File

@ -224,6 +224,7 @@ export default {
modelSaveFailed: 'Model switch was not saved — the next message may still use the previous model',
openSessions: 'Session Admin',
clearMessages: 'Clear Messages',
scrollToBottom: 'Back to bottom',
goToModelSettings: 'Go to Model Settings',
configModelFirst: 'Please configure a model first',
modelUnavailable: 'Current model is unavailable',

View File

@ -224,6 +224,7 @@ export default {
modelSaveFailed: '模型切换未保存,下条消息可能仍走原模型',
openSessions: '会话管理',
clearMessages: '清空消息',
scrollToBottom: '回到底部',
goToModelSettings: '前往模型设置',
configModelFirst: '请先配置模型',
modelUnavailable: '当前模型不可用',