) => {
+ 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 {