diff --git a/src/renderer/src/ConversationSidebar.tsx b/src/renderer/src/ConversationSidebar.tsx
index ac04536c..0f3234c3 100644
--- a/src/renderer/src/ConversationSidebar.tsx
+++ b/src/renderer/src/ConversationSidebar.tsx
@@ -632,9 +632,13 @@ export const ConversationSidebar = memo(function ConversationSidebar({
listScope,
Boolean(conversationActionsId),
);
+ // Same ids keep the same array: conversations change on every streamed
+ // update, and a new array would rerun the window's layout effect, which
+ // reads scrollTop and forces a synchronous layout mid-stream.
+ const filteredIdsKey = filteredConversations.map((conversation) => conversation.id).join("\n");
const filteredIds = useMemo(
- () => filteredConversations.map((conversation) => conversation.id),
- [filteredConversations],
+ () => (filteredIdsKey ? filteredIdsKey.split("\n") : []),
+ [filteredIdsKey],
);
const listWindow = useConversationListWindow({
ids: filteredIds,
diff --git a/src/renderer/src/OverflowMarquee.test.tsx b/src/renderer/src/OverflowMarquee.test.tsx
index cb556106..a2335803 100644
--- a/src/renderer/src/OverflowMarquee.test.tsx
+++ b/src/renderer/src/OverflowMarquee.test.tsx
@@ -54,6 +54,37 @@ describe('OverflowMarquee', () => {
).toBe('')
})
+ it('measures from the resize observer instead of forcing layout on mount', () => {
+ let callback: ResizeObserverCallback | undefined
+ class FakeResizeObserver {
+ constructor(next: ResizeObserverCallback) { callback = next }
+ observe(): void {}
+ disconnect(): void {}
+ }
+ const original = globalThis.ResizeObserver
+ globalThis.ResizeObserver = FakeResizeObserver as unknown as typeof ResizeObserver
+ try {
+ const text = '这是一个明显超过会话列表宽度的完整会话名称'
+ let reads = 0
+ const scrollWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollWidth')
+ Object.defineProperty(HTMLElement.prototype, 'scrollWidth', { configurable: true, get: () => { reads += 1; return 280 } })
+ try {
+ render()
+ const container = screen.getByTitle(text)
+ setMeasuredWidth(container, 'clientWidth', 120)
+ expect(reads).toBe(0)
+ callback?.([], {} as ResizeObserver)
+ expect(reads).toBe(1)
+ expect(container).toHaveAttribute('data-overflowing', 'true')
+ } finally {
+ if (scrollWidth) Object.defineProperty(HTMLElement.prototype, 'scrollWidth', scrollWidth)
+ else Reflect.deleteProperty(HTMLElement.prototype, 'scrollWidth')
+ }
+ } finally {
+ globalThis.ResizeObserver = original
+ }
+ })
+
it('keeps one readable text copy while its visual track moves', () => {
render()
diff --git a/src/renderer/src/OverflowMarquee.tsx b/src/renderer/src/OverflowMarquee.tsx
index 874a705f..33cc7ae1 100644
--- a/src/renderer/src/OverflowMarquee.tsx
+++ b/src/renderer/src/OverflowMarquee.tsx
@@ -54,7 +54,6 @@ export function OverflowMarquee({
}, [])
useLayoutEffect(() => {
- measureOverflow()
const container = containerRef.current
const track = trackRef.current
if (
@@ -62,11 +61,15 @@ export function OverflowMarquee({
!track ||
typeof ResizeObserver !== 'function'
) {
+ measureOverflow()
window.addEventListener('resize', measureOverflow)
return () =>
window.removeEventListener('resize', measureOverflow)
}
+ // The observer reports both elements once after layout (before paint),
+ // so mounting many rows does not force a synchronous layout per row. A
+ // text change re-creates the observer, whose first report re-measures.
const observer = new ResizeObserver(measureOverflow)
observer.observe(container)
observer.observe(track)