fix: keep the mentioned message in view when long messages collapse

Tall message bubbles above the target collapse to a max height right after they render. That shrank the space above and pushed the mentioned message off screen. We now re-pin the message until the layout stops moving.
This commit is contained in:
Abhinav Raut
2026-05-29 16:07:53 +05:30
parent f331b3cd8e
commit 67ecf3a3a6
2 changed files with 69 additions and 36 deletions
@@ -1,7 +1,7 @@
<template>
<div class="flex flex-col relative h-full">
<div ref="threadEl" class="flex-1 overflow-y-auto [overflow-anchor:none]" @scroll="handleScroll">
<div ref="contentEl" class="min-h-full px-4 pb-10">
<div ref="contentEl" class="min-h-full px-4 pb-10 relative">
<div
v-if="showLoadMore"
class="text-center mt-3"
@@ -25,11 +25,11 @@
<MessagesSkeleton :count="10" v-if="conversationStore.messages.loading" />
<TransitionGroup v-else enter-active-class="animate-slide-in" tag="div">
<TransitionGroup v-else enter-active-class="animate-slide-in" leave-active-class="message-leaving" tag="div">
<div
v-for="row in messageRows"
:key="row.message.uuid"
v-scroll-target="row.message.uuid"
:data-message-uuid="row.message.uuid"
:class="[row.spacingClass, { 'my-2': row.message.type === 'activity' }]"
>
<div v-if="!row.message.private && row.message.type !== 'activity'">
@@ -86,6 +86,11 @@ import MessagesSkeleton from './MessagesSkeleton.vue'
import { TypingIndicator } from '@shared-ui/components/TypingIndicator'
import { useStickyScroll } from '@shared-ui/composables'
const MENTION_TOP_OFFSET_RATIO = 0.25
const MENTION_SETTLE_FRAMES = 3
const MENTION_MAX_ANCHOR_FRAMES = 90
const HIGHLIGHT_MS = 2500
const route = useRoute()
const conversationStore = useConversationStore()
@@ -95,6 +100,7 @@ const contentEl = ref(null)
const emitter = useEmitter()
const unReadMessages = ref(0)
let currentConversationUUID = ''
let openScrollDone = false
const { hasUserScrolled, scrollToBottom, scrollToOffset, handleScroll } = useStickyScroll(threadEl, contentEl, {
onArriveBottom: () => { unReadMessages.value = 0 }
@@ -105,13 +111,31 @@ const handleScrollToBottom = () => {
scrollToBottom()
}
const vScrollTarget = {
mounted (el, binding) {
if (binding.value !== route.query.scrollTo || !threadEl.value) return
const applyOpenScroll = () => {
const thread = threadEl.value
if (!thread) return
const targetUUID = route.query.scrollTo
const targetEl = targetUUID ? thread.querySelector(`[data-message-uuid="${targetUUID}"]`) : null
if (targetEl) {
hasUserScrolled.value = true
scrollToOffset(Math.max(0, el.offsetTop - threadEl.value.clientHeight / 3 + el.offsetHeight / 2))
el.classList.add('highlight-mention')
setTimeout(() => el.classList.remove('highlight-mention'), 2500)
// Messages above the target collapse to max-h after mount, so re-pin until offsetTop stops moving.
let lastOffset = -1
let stableFrames = 0
let frames = 0
const anchorToTarget = () => {
if (!threadEl.value || !targetEl.isConnected) return
const offset = targetEl.offsetTop
scrollToOffset(Math.max(0, offset - threadEl.value.clientHeight * MENTION_TOP_OFFSET_RATIO))
stableFrames = offset === lastOffset ? stableFrames + 1 : 0
lastOffset = offset
if (stableFrames < MENTION_SETTLE_FRAMES && ++frames < MENTION_MAX_ANCHOR_FRAMES) requestAnimationFrame(anchorToTarget)
}
anchorToTarget()
targetEl.classList.add('highlight-mention')
setTimeout(() => targetEl.classList.remove('highlight-mention'), HIGHLIGHT_MS)
} else {
hasUserScrolled.value = false
scrollToBottom()
}
}
@@ -138,18 +162,19 @@ watch(
if (!newUUID || newUUID === currentConversationUUID) return
currentConversationUUID = newUUID
unReadMessages.value = 0
hasUserScrolled.value = !!route.query.scrollTo
openScrollDone = false
}
)
watch(
() => conversationStore.conversationMessages.length,
(newLen, oldLen) => {
if (oldLen === 0 && newLen > 0 && !route.query.scrollTo) {
hasUserScrolled.value = false
nextTick(scrollToBottom)
}
}
() => [conversationStore.conversationMessages.length, conversationStore.messages.loading],
([len, loading]) => {
if (openScrollDone || loading || !len) return
openScrollDone = true
hasUserScrolled.value = !!route.query.scrollTo
nextTick(applyOpenScroll)
},
{ immediate: true }
)
// Watch for typing indicator and auto-scroll if user is at bottom
@@ -213,32 +238,38 @@ const messageRows = computed(() => {
</script>
<style scoped>
/* Leaving messages must be out of flow during a conversation swap, else they shift the target's offsetTop mid-scroll. */
.message-leaving {
position: absolute;
opacity: 0;
pointer-events: none;
}
/* Highlight via an opacity-faded overlay, not the element's own background, to avoid a text repaint flicker when it ends. */
.highlight-mention {
animation: highlightPulse 2.5s ease-out;
position: relative;
}
@keyframes highlightPulse {
0% {
background-color: rgb(251 191 36 / 0.35);
border-radius: 0.5rem;
}
100% {
background-color: transparent;
}
.highlight-mention::after {
content: '';
position: absolute;
inset: 0;
border-radius: 0.5rem;
background-color: rgb(251 191 36 / 0.35);
pointer-events: none;
animation: highlightFade 2.5s ease-out forwards;
}
/* Dark mode highlight - softer yellow */
:global(.dark) .highlight-mention {
animation: highlightPulseDark 2.5s ease-out;
:global(.dark) .highlight-mention::after {
background-color: rgb(250 204 21 / 0.2);
}
@keyframes highlightPulseDark {
0% {
background-color: rgb(250 204 21 / 0.2);
border-radius: 0.5rem;
@keyframes highlightFade {
from {
opacity: 1;
}
100% {
background-color: transparent;
to {
opacity: 0;
}
}
</style>
@@ -50,6 +50,7 @@
<script setup>
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { useStorage, useDocumentVisibility } from '@vueuse/core'
import { ChevronLeft } from 'lucide-vue-next'
import { useConversationStore } from '@main/stores/conversation'
@@ -64,6 +65,7 @@ const props = defineProps({
})
const conversationStore = useConversationStore()
const route = useRoute()
const emitter = useEmitter()
const sidebarPanelRef = ref(null)
const sidebarOpen = useStorage('conversationSidebarOpen', true)
@@ -141,7 +143,7 @@ watch(
() => props.uuid,
(newUUID, oldUUID) => {
if (!newUUID || newUUID === oldUUID) return
const canTransition = oldUUID && typeof document.startViewTransition === 'function'
const canTransition = oldUUID && !route.query.scrollTo && typeof document.startViewTransition === 'function'
if (!canTransition) {
fetchConversation(newUUID)
return