From dc91a461d60f1a60c8e00aa89ddfd1895e1ee9f1 Mon Sep 17 00:00:00 2001 From: murashit Date: Sun, 21 Jun 2026 23:10:31 +0900 Subject: [PATCH] Fix work details stream spacing --- .../chat/ui/message-stream/virtualizer.ts | 35 ++++++++++++++++++- .../ui/message-stream/virtualizer.test.ts | 21 +++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src/features/chat/ui/message-stream/virtualizer.ts b/src/features/chat/ui/message-stream/virtualizer.ts index 83fbf3c6..168d5918 100644 --- a/src/features/chat/ui/message-stream/virtualizer.ts +++ b/src/features/chat/ui/message-stream/virtualizer.ts @@ -52,11 +52,12 @@ interface MessageVirtualizerRuntime { viewportRestoreFrame: number | null; virtualizerChangeFrame: number | null; settleScrollToEndFrame: number | null; + anchorClampFrame: number | null; settleScrollToEndAttemptsRemaining: number; userScrollIntentUntil: number; } -type MessageVirtualizerFrameKey = "viewportRestoreFrame" | "virtualizerChangeFrame" | "settleScrollToEndFrame"; +type MessageVirtualizerFrameKey = "viewportRestoreFrame" | "virtualizerChangeFrame" | "settleScrollToEndFrame" | "anchorClampFrame"; export interface MessageStreamVirtualizerOptions { blocks: readonly MessageStreamBlock[]; @@ -160,6 +161,7 @@ function createMessageVirtualizerRuntime(): MessageVirtualizerRuntime { viewportRestoreFrame: null, virtualizerChangeFrame: null, settleScrollToEndFrame: null, + anchorClampFrame: null, settleScrollToEndAttemptsRemaining: 0, userScrollIntentUntil: 0, }; @@ -384,6 +386,7 @@ function scrollMessageVirtualizerByPage(runtime: MessageVirtualizerRuntime, dire function disposeMessageVirtualizer(runtime: MessageVirtualizerRuntime): void { cancelViewportRestoreMessageVirtualizerReset(runtime); cancelDeferredMessageVirtualizerChange(runtime); + cancelPreservedMessageVirtualizerEndClamp(runtime); cancelPendingMessageVirtualizerBottomFollow(runtime); runtime.cleanupVirtualizer?.(); runtime.cleanupVirtualizer = null; @@ -406,6 +409,7 @@ function attachMessageVirtualizer(runtime: MessageVirtualizerRuntime, container: function detachMessageVirtualizer(runtime: MessageVirtualizerRuntime): void { cancelViewportRestoreMessageVirtualizerReset(runtime); cancelDeferredMessageVirtualizerChange(runtime); + cancelPreservedMessageVirtualizerEndClamp(runtime); cancelPendingMessageVirtualizerBottomFollow(runtime); runtime.cleanupVirtualizer?.(); runtime.cleanupVirtualizer = null; @@ -419,6 +423,7 @@ function detachMessageVirtualizer(runtime: MessageVirtualizerRuntime): void { function resetMessageVirtualizer(runtime: MessageVirtualizerRuntime, container: HTMLElement): void { cancelViewportRestoreMessageVirtualizerReset(runtime); cancelDeferredMessageVirtualizerChange(runtime); + cancelPreservedMessageVirtualizerEndClamp(runtime); cancelSettledMessageVirtualizerScrollToEnd(runtime); runtime.cleanupVirtualizer?.(); runtime.cleanupVirtualizer = null; @@ -656,6 +661,31 @@ function cancelSettledMessageVirtualizerScrollToEnd(runtime: MessageVirtualizerR runtime.settleScrollToEndAttemptsRemaining = 0; } +function schedulePreservedMessageVirtualizerEndClamp(runtime: MessageVirtualizerRuntime): void { + const container = runtime.container; + if (!container || runtime.anchorClampFrame !== null) return; + scheduleMessageVirtualizerFrame(runtime, "anchorClampFrame", container, () => { + if (runtime.container !== container || !isMessageVirtualizerPreservingReadingAnchor(runtime)) return; + const scrollEnd = Math.max(0, runtime.virtualizer.getTotalSize() - container.clientHeight); + if (Math.abs(container.scrollTop - scrollEnd) <= MESSAGE_BOTTOM_THRESHOLD) { + updateMessageVirtualizer(runtime.virtualizer); + markMessageVirtualizerFollowEndIntent(runtime); + notifyMessageVirtualizerChange(runtime); + return; + } + if (container.scrollTop < scrollEnd) return; + runtime.virtualizer.scrollToOffset(scrollEnd); + syncMessageVirtualizerScrollOffset(runtime); + updateMessageVirtualizer(runtime.virtualizer); + markMessageVirtualizerFollowEndIntent(runtime); + notifyMessageVirtualizerChange(runtime); + }); +} + +function cancelPreservedMessageVirtualizerEndClamp(runtime: MessageVirtualizerRuntime): void { + cancelMessageVirtualizerFrame(runtime, "anchorClampFrame"); +} + function scheduleMessageVirtualizerFrame( runtime: MessageVirtualizerRuntime, key: MessageVirtualizerFrameKey, @@ -743,6 +773,9 @@ function measureMessageElement( ): number { const box = entry?.borderBoxSize[0]; const size = box ? Math.round(box.blockSize) : element.offsetHeight || instance.options.estimateSize(instance.indexFromElement(element)); + if (entry && isMessageVirtualizerPreservingReadingAnchor(runtime)) { + schedulePreservedMessageVirtualizerEndClamp(runtime); + } if (entry && hasMessageVirtualizerFollowEndIntent(runtime)) { // ResizeObserver measurements can arrive while TanStack and DOM heights are still converging, so keep the bounded settle. scheduleMessageVirtualizerBottomReconcileAfterCommit(runtime); diff --git a/tests/features/chat/ui/message-stream/virtualizer.test.ts b/tests/features/chat/ui/message-stream/virtualizer.test.ts index 40c62f6e..2647b5c6 100644 --- a/tests/features/chat/ui/message-stream/virtualizer.test.ts +++ b/tests/features/chat/ui/message-stream/virtualizer.test.ts @@ -463,6 +463,27 @@ describe("TestMessageStreamVirtualizer", () => { }); }); + it("clamps stale unpinned offsets when a visible item shrinks near the end", () => { + withResizeObserverEntries((resizeElement, flushFrames) => { + const container = messageContainer({ scrollTop: 0, clientHeight: 100 }); + const controller = createMessageStreamVirtualizerDriver(container); + const details = measuredElement("details", 1, 500); + + renderVirtualItems(controller, container, ["first", "details", "last"], [300, 500, 270], "force-bottom"); + controller.measureElement(details); + userScrollTo(container, 720); + + container.dataset["testTotalSize"] = "590"; + container.dataset["testScrollHeight"] = "1070"; + resizeElement(details, 20); + flushFrames(); + + expect(controller.getTotalSize()).toBe(590); + expect(container.scrollTop).toBe(490); + controller.dispose(); + }); + }); + it("keeps the reading position when the message viewport shrinks away from the end", () => { withResizeObserver((triggerResize) => { const container = messageContainer({ scrollTop: 0, clientHeight: 160 });