diff --git a/src/renderer/src/components/message/MessageBlockThink.vue b/src/renderer/src/components/message/MessageBlockThink.vue
index d93943bd0..e398f2ffb 100644
--- a/src/renderer/src/components/message/MessageBlockThink.vue
+++ b/src/renderer/src/components/message/MessageBlockThink.vue
@@ -22,6 +22,10 @@ const props = defineProps<{
reasoning_end_time: number
}
}>()
+
+const emit = defineEmits<{
+ (e: 'toggle-collapse', isCollapsed: boolean): void
+}>()
const { t } = useI18n()
const configPresenter = usePresenter('configPresenter')
@@ -97,8 +101,9 @@ const headerText = computed(() => {
watch(
() => collapse.value,
- () => {
- configPresenter.setSetting('think_collapse', collapse.value)
+ (newValue) => {
+ configPresenter.setSetting('think_collapse', newValue)
+ emit('toggle-collapse', !newValue)
}
)
diff --git a/src/renderer/src/components/message/MessageItemAssistant.vue b/src/renderer/src/components/message/MessageItemAssistant.vue
index b02bd8403..04310749b 100644
--- a/src/renderer/src/components/message/MessageItemAssistant.vue
+++ b/src/renderer/src/components/message/MessageItemAssistant.vue
@@ -35,6 +35,7 @@
v-else-if="block.type === 'reasoning_content' && block.content"
:block="block"
:usage="message.usage"
+ @toggle-collapse="handleCollapseToggle"
/>
{
+ emit('variantChanged', props.message.id)
+}
+
const handleAction = (action: HandleActionType) => {
if (action === 'retry') {
chatStore.retryMessage(currentMessage.value.id)
diff --git a/src/renderer/src/components/message/MessageList.vue b/src/renderer/src/components/message/MessageList.vue
index 2e5a22449..83866bf77 100644
--- a/src/renderer/src/components/message/MessageList.vue
+++ b/src/renderer/src/components/message/MessageList.vue
@@ -16,7 +16,11 @@
@@ -129,6 +133,10 @@ const shouldAutoFollow = ref(true)
const traceMessageId = ref(null)
let highlightRefreshTimer: number | null = null
+const previousHeights = new Map()
+const messageResizeObservers = new Map()
+const pendingHeightUpdate = ref(false)
+
// === Composable Integrations ===
// Scroll management
const scroll = useMessageScroll({
@@ -184,6 +192,77 @@ const MAX_REASONABLE_HEIGHT = 2000
const BUFFER_ZONE_MULTIPLIER = 2
const EXTREME_POSITION_THRESHOLD = 100000
+const HEIGHT_CHANGE_THRESHOLD = 10
+const SCROLL_CHECK_DELAY = 150
+
+const trackMessageHeightChange = (messageId: string, newHeight: number) => {
+ const previousHeight = previousHeights.get(messageId) ?? 0
+ const heightDiff = Math.abs(newHeight - previousHeight)
+
+ if (heightDiff > HEIGHT_CHANGE_THRESHOLD && heightDiff < MAX_REASONABLE_HEIGHT) {
+ if (!pendingHeightUpdate.value) {
+ pendingHeightUpdate.value = true
+ nextTick(() => {
+ scrollerUpdate()
+ setTimeout(() => {
+ pendingHeightUpdate.value = false
+ }, SCROLL_CHECK_DELAY)
+ })
+ }
+ }
+
+ previousHeights.set(messageId, newHeight)
+}
+
+const scrollerUpdate = () => {
+ const scroller = dynamicScrollerRef.value
+ scroller?.updateVisibleItems?.(true)
+}
+
+const setupMessageResizeObserver = () => {
+ const container = messagesContainer.value
+ if (!container) return
+
+ const observerCallback = (entries: ResizeObserverEntry[]) => {
+ for (const entry of entries) {
+ const messageId = entry.target.getAttribute('data-message-id')
+ if (!messageId) continue
+
+ const newHeight = entry.contentRect.height
+ trackMessageHeightChange(messageId, newHeight)
+ }
+ }
+
+ const debouncedObserverCallback = useDebounceFn(observerCallback, 100)
+
+ const visibleMessages = container.querySelectorAll('[data-message-id]')
+
+ visibleMessages.forEach((element) => {
+ const el = element as HTMLElement
+ const messageId = el.getAttribute('data-message-id')
+ if (!messageId) return
+
+ const previousObserver = messageResizeObservers.get(messageId)
+ if (previousObserver) {
+ previousObserver.disconnect()
+ }
+
+ const observer = new ResizeObserver(debouncedObserverCallback)
+ observer.observe(el)
+ messageResizeObservers.set(messageId, observer)
+
+ previousHeights.set(messageId, el.getBoundingClientRect().height)
+ })
+}
+
+const cleanupMessageResizeObservers = () => {
+ messageResizeObservers.forEach((observer) => {
+ observer.disconnect()
+ })
+ messageResizeObservers.clear()
+ previousHeights.clear()
+}
+
// === Helper Functions ===
const getTextLength = (value?: string) => value?.length ?? 0
@@ -222,6 +301,30 @@ const getVariantSizeKey = (item: MessageListItem) => {
return chatStore.selectedVariantsMap[message.id] ?? ''
}
+const getRenderingStateKey = (item: MessageListItem) => {
+ const message = item.message
+ if (!message) return `rendering:placeholder:${item.id}`
+
+ const loadingStates: string[] = []
+
+ if (message.role === 'assistant') {
+ const blocks = (message as AssistantMessage).content
+ if (Array.isArray(blocks)) {
+ blocks.forEach((block) => {
+ if (block.status === 'loading') {
+ loadingStates.push(`${block.type}`)
+ }
+ })
+ }
+ }
+
+ if (loadingStates.length > 0) {
+ return `rendering:loading:${loadingStates.sort().join(',')}`
+ }
+
+ return ''
+}
+
// === Event Handlers ===
const handleCopyImage = async (
messageId: string,
@@ -562,6 +665,9 @@ const refreshVirtualScroller = async (messageId?: string) => {
}
scroller?.updateVisibleItems?.(true)
+
+ await new Promise((resolve) => requestAnimationFrame(resolve))
+ scroller?.updateVisibleItems?.(true)
}
const wrapScrollToMessage = async (messageId: string) => {
@@ -583,6 +689,7 @@ const handleVirtualUpdate = (
void chatStore.prefetchMessagesForRange(safeStart, safeEnd)
recordVisibleDomInfo()
handleVirtualScrollUpdate()
+ setupMessageResizeObserver()
}
watch(
@@ -595,13 +702,14 @@ watch(
onMounted(() => {
bindScrollContainer()
- // Initialize scroll and visibility
+
scrollToBottom(true)
nextTick(() => {
visible.value = true
setupScrollObserver()
updateScrollInfo()
recordVisibleDomInfo()
+ setupMessageResizeObserver()
})
useResizeObserver(messagesContainer, () => {
@@ -634,10 +742,15 @@ onMounted(() => {
watch(
() => {
const lastMessage = props.items[props.items.length - 1]
- return lastMessage ? getMessageSizeKey(lastMessage) : ''
+ return lastMessage
+ ? `${getMessageSizeKey(lastMessage)}:${getRenderingStateKey(lastMessage)}`
+ : ''
},
() => {
scrollToBottom()
+ nextTick(() => {
+ scrollerUpdate()
+ })
},
{ flush: 'post' }
)
@@ -650,6 +763,7 @@ onBeforeUnmount(() => {
if (highlightRefreshTimer) clearTimeout(highlightRefreshTimer)
highlightRefreshTimer = null
+ cleanupMessageResizeObservers()
})
// === Expose ===
diff --git a/src/renderer/src/composables/message/useMessageScroll.ts b/src/renderer/src/composables/message/useMessageScroll.ts
index bd3cc65b9..b0a6c7441 100644
--- a/src/renderer/src/composables/message/useMessageScroll.ts
+++ b/src/renderer/src/composables/message/useMessageScroll.ts
@@ -5,8 +5,8 @@ import type { DynamicScroller } from 'vue-virtual-scroller'
// === Constants ===
const MESSAGE_HIGHLIGHT_CLASS = 'message-highlight'
-const MAX_SCROLL_RETRIES = 8
-const SCROLL_RETRY_DELAY = 50
+const MAX_SCROLL_RETRIES = 12
+const SCROLL_RETRY_DELAY = 80
const HIGHLIGHT_DURATION = 2000
const PLACEHOLDER_POSITION_THRESHOLD = 5000