diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx index 3f90945b0..05c42e977 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx @@ -50,6 +50,7 @@ import { deriveTurnModelState, reconcileMeasuredScrollTop, shouldAbsorbProgrammaticScrollEvent, + shouldStickToBottomAfterScroll, } from "./AgentChatMessageList"; function findButtonByTextContent(matcher: RegExp): HTMLButtonElement { @@ -696,6 +697,83 @@ describe("AgentChatMessageList transcript rendering", () => { }); }); + it("does not resume bottom stickiness until the user returns to latest", () => { + expect(shouldStickToBottomAfterScroll({ + distanceFromBottom: 80, + wasStuckToBottom: true, + })).toBe(true); + expect(shouldStickToBottomAfterScroll({ + distanceFromBottom: 80, + wasStuckToBottom: false, + })).toBe(false); + expect(shouldStickToBottomAfterScroll({ + distanceFromBottom: 12, + wasStuckToBottom: false, + })).toBe(true); + }); + + it("lets upward wheel intent break bottom-follow before streaming output grows", async () => { + const events: AgentChatEventEnvelope[] = [ + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { + type: "user_message", + text: "Start streaming", + deliveryState: "delivered", + }, + }, + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:01.000Z", + event: { + type: "text", + text: "Streaming chunk", + itemId: "text-1", + turnId: "turn-1", + }, + }, + ]; + const view = renderMessageList(events, { showStreamingIndicator: true }); + + const transcript = document.querySelector(".ade-chat-timeline-pane") as HTMLDivElement; + Object.defineProperty(transcript, "scrollHeight", { configurable: true, value: 1_000 }); + Object.defineProperty(transcript, "clientHeight", { configurable: true, value: 200 }); + transcript.scrollTop = 800; + + fireEvent.wheel(transcript, { deltaY: -80 }); + transcript.scrollTop = 760; + fireEvent.scroll(transcript); + + expect(await screen.findByRole("button", { name: "Jump to latest message" })).toBeTruthy(); + + Object.defineProperty(transcript, "scrollHeight", { configurable: true, value: 1_100 }); + view.rerender( + + + + , + ); + + await new Promise((resolve) => window.requestAnimationFrame(() => resolve())); + expect(transcript.scrollTop).toBe(760); + }); + it("jumps through the user message minimap", () => { renderMessageList([ { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index 385a0dd54..5eefb7d85 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -3553,6 +3553,8 @@ const VIRTUALIZATION_THRESHOLD = 60; * auto-follow rather than being snapped back. */ const STICK_THRESHOLD_PX = 160; +const STICK_RESUME_THRESHOLD_PX = 24; +const TOUCH_SCROLL_DEADBAND_PX = 2; export function shouldAbsorbProgrammaticScrollEvent({ scrollTop, @@ -3564,6 +3566,18 @@ export function shouldAbsorbProgrammaticScrollEvent({ return programmaticTarget != null && Math.abs(scrollTop - programmaticTarget) < 1; } +export function shouldStickToBottomAfterScroll({ + distanceFromBottom, + wasStuckToBottom, +}: { + distanceFromBottom: number; + wasStuckToBottom: boolean; +}): boolean { + return wasStuckToBottom + ? distanceFromBottom < STICK_THRESHOLD_PX + : distanceFromBottom <= STICK_RESUME_THRESHOLD_PX; +} + export function calculateVirtualWindow({ rowCount, scrollTop, @@ -4091,6 +4105,7 @@ function AgentChatMessageListMain({ // into at most one scrollTop assignment per frame. const scrollRafRef = useRef(null); const scrollFollowFramesRef = useRef(0); + const lastTouchYRef = useRef(null); // Programmatic scroll writes can be coalesced by the browser. Track the // latest ADE-authored scrollTop target instead of using a counter, so a real // user scroll never gets swallowed by stale "programmatic" credits. @@ -4279,6 +4294,19 @@ function AgentChatMessageListMain({ scrollRafRef.current = requestAnimationFrame(run); }, []); + const releaseBottomStickinessForUserScroll = useCallback(() => { + const el = scrollRef.current; + if (!el || el.scrollHeight <= el.clientHeight + 1 || !stickToBottomRef.current) return; + stickToBottomRef.current = false; + setStickToBottom(false); + scrollFollowFramesRef.current = 0; + programmaticScrollTargetRef.current = null; + if (scrollRafRef.current !== null) { + cancelAnimationFrame(scrollRafRef.current); + scrollRafRef.current = null; + } + }, []); + useEffect(() => () => { if (scrollRafRef.current !== null) { cancelAnimationFrame(scrollRafRef.current); @@ -4436,7 +4464,10 @@ function AgentChatMessageListMain({ // Wider threshold (~1 row of assistant text) so a small wheel nudge // while the turn is streaming actually breaks free instead of snapping // straight back to the bottom. - const nextStick = distanceFromBottom < STICK_THRESHOLD_PX; + const nextStick = shouldStickToBottomAfterScroll({ + distanceFromBottom, + wasStuckToBottom: stickToBottomRef.current, + }); if (nextStick !== stickToBottomRef.current) { stickToBottomRef.current = nextStick; setStickToBottom(nextStick); @@ -4444,6 +4475,29 @@ function AgentChatMessageListMain({ setScrollTop(target.scrollTop); }, []); + const handleWheel = useCallback((event: React.WheelEvent) => { + if (event.deltaY < 0) { + releaseBottomStickinessForUserScroll(); + } + }, [releaseBottomStickinessForUserScroll]); + + const handleTouchStart = useCallback((event: React.TouchEvent) => { + lastTouchYRef.current = event.touches[0]?.clientY ?? null; + }, []); + + const handleTouchMove = useCallback((event: React.TouchEvent) => { + const nextY = event.touches[0]?.clientY ?? null; + const previousY = lastTouchYRef.current; + if (nextY != null && previousY != null && nextY - previousY > TOUCH_SCROLL_DEADBAND_PX) { + releaseBottomStickinessForUserScroll(); + } + lastTouchYRef.current = nextY; + }, [releaseBottomStickinessForUserScroll]); + + const handleTouchEnd = useCallback(() => { + lastTouchYRef.current = null; + }, []); + const jumpToLatest = useCallback(() => { stickToBottomRef.current = true; setStickToBottom(true); @@ -4642,6 +4696,11 @@ function AgentChatMessageListMain({ ref={scrollRef} className="ade-chat-timeline-pane h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto pl-[length:var(--chat-timeline-pad-x)] pr-[length:var(--chat-timeline-pad-x)] pt-[length:var(--chat-timeline-pad-top)] pb-[length:var(--chat-timeline-pad-bottom)]" onScroll={handleScroll} + onWheel={handleWheel} + onTouchStart={handleTouchStart} + onTouchMove={handleTouchMove} + onTouchEnd={handleTouchEnd} + onTouchCancel={handleTouchEnd} >
{rows.length === 0 && !streamingIndicator ? ( diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx index 902b34c6d..030519dda 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx @@ -5526,6 +5526,31 @@ describe("mergeChatHistorySnapshot", () => { "same millisecond tail", ]); }); + + it("preserves existing event object identity when a recovery snapshot is unchanged", () => { + const first = envelope("2026-04-30T23:14:47.751Z", 1003, "first"); + const second = envelope("2026-04-30T23:19:57.083Z", 1004, "second"); + const parsedFirst = envelope("2026-04-30T23:14:47.751Z", 1003, "first"); + const parsedSecond = envelope("2026-04-30T23:19:57.083Z", 1004, "second"); + + const existing = [first, second]; + const merged = mergeChatHistorySnapshot([parsedFirst, parsedSecond], existing); + + expect(merged).toBe(existing); + expect(merged[0]).toBe(first); + expect(merged[1]).toBe(second); + }); + + it("reuses existing snapshot entries while appending newly recovered events", () => { + const first = envelope("2026-04-30T23:14:47.751Z", 1003, "first"); + const parsedFirst = envelope("2026-04-30T23:14:47.751Z", 1003, "first"); + const parsedSecond = envelope("2026-04-30T23:19:57.083Z", 1004, "second"); + + const merged = mergeChatHistorySnapshot([parsedFirst, parsedSecond], [first]); + + expect(merged[0]).toBe(first); + expect(merged[1]).toBe(parsedSecond); + }); }); describe("subagent auto-open storage", () => { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 61496e5f3..0cb64ab60 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -1269,7 +1269,17 @@ export function mergeChatHistorySnapshot( if (!existing.length) return parsed; if (!parsed.length) return existing; - const parsedKeys = new Set(parsed.map(chatEventDedupKey)); + const existingByKey = new Map(); + for (const entry of existing) { + const key = chatEventDedupKey(entry); + if (!existingByKey.has(key)) existingByKey.set(key, entry); + } + const parsedKeys = new Set(); + const normalizedParsed = parsed.map((entry) => { + const key = chatEventDedupKey(entry); + parsedKeys.add(key); + return existingByKey.get(key) ?? entry; + }); const lastParsedKey = chatEventDedupKey(parsed[parsed.length - 1]!); let overlapIndex = -1; for (let index = existing.length - 1; index >= 0; index -= 1) { @@ -1290,7 +1300,11 @@ export function mergeChatHistorySnapshot( return entry.timestamp > parsed[parsed.length - 1]!.timestamp; }); const tail = tailCandidates.filter((entry) => !parsedKeys.has(chatEventDedupKey(entry))); - return tail.length ? [...parsed, ...tail] : parsed; + const merged = tail.length ? [...normalizedParsed, ...tail] : normalizedParsed; + if (merged.length === existing.length && merged.every((entry, index) => entry === existing[index])) { + return existing; + } + return merged; } function pruneSessionRecord(record: Record, keepIds: ReadonlySet): Record {