Improve Chat scroll-to-bottom reliability with ResizeObserver
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m1s

Replace ad-hoc nextTick and media load handlers with a ResizeObserver
on an inner content wrapper, which fires after layout for all content
changes (new messages, image/video loads, window resize). Add scroll
position tracking so auto-scroll only triggers when user is near
bottom, and conditionally show the Bottom button only when scrolled up.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-25 03:06:27 +00:00
parent 3afcee2011
commit bbb493b544

View File

@@ -11,21 +11,47 @@ const authStore = useAuthStore();
const messages = computed(() => messagesStore.messages); const messages = computed(() => messagesStore.messages);
const messageInput = ref(""); const messageInput = ref("");
const messagesContainer = ref(null); const messagesContainer = ref(null);
const messagesInner = ref(null);
const fileInput = ref(null); const fileInput = ref(null);
const isNearBottom = ref(true);
const SCROLL_THRESHOLD = 100;
let resizeObserver = null;
function scrollToBottom() { function scrollToBottom() {
nextTick(() => { if (messagesContainer.value) {
if (messagesContainer.value) { messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight;
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight; }
}
});
} }
watch(messages, scrollToBottom, { deep: true }); function scrollToBottomIfNear() {
if (isNearBottom.value) {
scrollToBottom();
}
}
function onScroll() {
if (!messagesContainer.value) return;
const { scrollHeight, scrollTop, clientHeight } = messagesContainer.value;
isNearBottom.value = scrollHeight - scrollTop - clientHeight < SCROLL_THRESHOLD;
}
function goToBottom() {
isNearBottom.value = true;
scrollToBottom();
}
watch(
() => messages.value.length,
() => {
nextTick(scrollToBottomIfNear);
},
);
function sendMessage() { function sendMessage() {
const text = messageInput.value.trim(); const text = messageInput.value.trim();
if (!text) return; if (!text) return;
isNearBottom.value = true;
messagesStore.sendMessage(text); messagesStore.sendMessage(text);
messageInput.value = ""; messageInput.value = "";
} }
@@ -33,6 +59,7 @@ function sendMessage() {
async function onFileSelected(e) { async function onFileSelected(e) {
const file = e.target.files[0]; const file = e.target.files[0];
if (!file) return; if (!file) return;
isNearBottom.value = true;
await messagesStore.uploadAndSendFile(file); await messagesStore.uploadAndSendFile(file);
fileInput.value.value = ""; fileInput.value.value = "";
} }
@@ -73,9 +100,30 @@ function parseMessageParts(text) {
onMounted(() => { onMounted(() => {
messagesStore.connect(); messagesStore.connect();
if (messagesContainer.value) {
messagesContainer.value.addEventListener("scroll", onScroll, { passive: true });
}
if (messagesInner.value) {
resizeObserver = new ResizeObserver(scrollToBottomIfNear);
resizeObserver.observe(messagesInner.value);
}
scrollToBottom();
}); });
onUnmounted(() => { onUnmounted(() => {
messagesStore.disconnect(); messagesStore.disconnect();
if (messagesContainer.value) {
messagesContainer.value.removeEventListener("scroll", onScroll);
}
if (resizeObserver) {
resizeObserver.disconnect();
resizeObserver = null;
}
}); });
</script> </script>
@@ -83,41 +131,41 @@ onUnmounted(() => {
<div class="chat-root flex-col flex min-h-0"> <div class="chat-root flex-col flex min-h-0">
<Header>Chat</Header> <Header>Chat</Header>
<div ref="messagesContainer" class="flex flex-col flex-1 min-h-0 overflow-y-auto overflow-x-hidden p-2 min-w-0"> <div ref="messagesContainer" class="flex flex-col flex-1 min-h-0 overflow-y-auto overflow-x-hidden p-2 min-w-0">
<p v-for="message in messages" :key="message.id" class="break-words min-w-0 w-full"> <div ref="messagesInner">
<span class="text-tertiary">{{ message.authorId }}:</span> <p v-for="message in messages" :key="message.id" class="break-words min-w-0 w-full">
<template <span class="text-tertiary">{{ message.authorId }}:</span>
v-for="(part, i) in parseMessageParts(message.text || '')" <template
:key="i" v-for="(part, i) in parseMessageParts(message.text || '')"
> :key="i"
<Link
v-if="part.type === 'link'"
bare
:href="part.value"
target="_blank"
class="text-primary underline break-all"
>{{ part.value }}</Link
> >
<span v-else>{{ part.value }}</span> <Link
</template> v-if="part.type === 'link'"
<template v-if="message.fileUrl && isSafeFileUrl(message.fileUrl)"> bare
<img :href="part.value"
v-if="isImageUrl(message.fileUrl)" target="_blank"
:src="message.fileUrl" class="text-primary underline break-all"
class="w-full max-w-full max-h-48 rounded block" >{{ part.value }}</Link
@load="scrollToBottom" >
/> <span v-else>{{ part.value }}</span>
<video </template>
v-else-if="isVideoUrl(message.fileUrl)" <template v-if="message.fileUrl && isSafeFileUrl(message.fileUrl)">
:src="message.fileUrl" <img
controls v-if="isImageUrl(message.fileUrl)"
class="w-full max-w-full max-h-48 rounded block" :src="message.fileUrl"
@loadedmetadata="scrollToBottom" class="w-full max-w-full max-h-48 rounded block"
/> />
<Link v-else bare :href="message.fileUrl" target="_blank" class="underline break-all">{{ <video
message.fileUrl.split("/").pop() v-else-if="isVideoUrl(message.fileUrl)"
}}</Link> :src="message.fileUrl"
</template> controls
</p> class="w-full max-w-full max-h-48 rounded block"
/>
<Link v-else bare :href="message.fileUrl" target="_blank" class="underline break-all">{{
message.fileUrl.split("/").pop()
}}</Link>
</template>
</p>
</div>
</div> </div>
<div> <div>
<input v-model="messageInput" @keyup.enter="sendMessage" /> <input v-model="messageInput" @keyup.enter="sendMessage" />
@@ -135,7 +183,7 @@ onUnmounted(() => {
@click="fileInput.click()" @click="fileInput.click()"
>Attach</Button >Attach</Button
> >
<Button class="flex-1" @click="scrollToBottom">Bottom</Button> <Button v-if="!isNearBottom" class="flex-1" @click="goToBottom">Bottom</Button>
</div> </div>
</div> </div>
</div> </div>