Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
deriveTurnModelState,
reconcileMeasuredScrollTop,
shouldAbsorbProgrammaticScrollEvent,
shouldStickToBottomAfterScroll,
} from "./AgentChatMessageList";

function findButtonByTextContent(matcher: RegExp): HTMLButtonElement {
Expand Down Expand Up @@ -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(
<MemoryRouter initialEntries={[{ pathname: "/" }]}>
<AgentChatMessageList
events={[
...events,
{
sessionId: "session-1",
timestamp: "2026-03-17T10:00:02.000Z",
event: {
type: "text",
text: "More streaming output",
itemId: "text-2",
turnId: "turn-1",
},
},
]}
showStreamingIndicator
/>
<LocationProbe />
</MemoryRouter>,
);

await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
expect(transcript.scrollTop).toBe(760);
});

it("jumps through the user message minimap", () => {
renderMessageList([
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
arul28 marked this conversation as resolved.
const TOUCH_SCROLL_DEADBAND_PX = 2;

export function shouldAbsorbProgrammaticScrollEvent({
scrollTop,
Expand All @@ -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,
Expand Down Expand Up @@ -4091,6 +4105,7 @@ function AgentChatMessageListMain({
// into at most one scrollTop assignment per frame.
const scrollRafRef = useRef<number | null>(null);
const scrollFollowFramesRef = useRef(0);
const lastTouchYRef = useRef<number | null>(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.
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -4436,14 +4464,40 @@ 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);
}
setScrollTop(target.scrollTop);
}, []);

const handleWheel = useCallback((event: React.WheelEvent<HTMLDivElement>) => {
if (event.deltaY < 0) {
releaseBottomStickinessForUserScroll();
}
}, [releaseBottomStickinessForUserScroll]);

const handleTouchStart = useCallback((event: React.TouchEvent<HTMLDivElement>) => {
lastTouchYRef.current = event.touches[0]?.clientY ?? null;
}, []);

const handleTouchMove = useCallback((event: React.TouchEvent<HTMLDivElement>) => {
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);
Expand Down Expand Up @@ -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}
>
<div ref={contentWrapperRef} className="min-w-0 max-w-full overflow-visible">
{rows.length === 0 && !streamingIndicator ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
18 changes: 16 additions & 2 deletions apps/desktop/src/renderer/components/chat/AgentChatPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, AgentChatEventEnvelope>();
for (const entry of existing) {
const key = chatEventDedupKey(entry);
if (!existingByKey.has(key)) existingByKey.set(key, entry);
}
const parsedKeys = new Set<string>();
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) {
Expand All @@ -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<T>(record: Record<string, T>, keepIds: ReadonlySet<string>): Record<string, T> {
Expand Down
Loading