Skip to content

Commit 43df308

Browse files
authored
Merge pull request #39 from tyulyukov/feature/feat/auto-scroll-diffs
feat(chat): integrate pierre diffs library and improve auto-scroll behavior
2 parents e6ee1c8 + 15beec3 commit 43df308

14 files changed

Lines changed: 248 additions & 128 deletions

apps/web/src/components/ChatView.logic.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@ const makeThread = (input?: {
204204
additionalDirectories: [],
205205
turnDiffSummaries: [],
206206
activities: [],
207+
hydrated: true,
207208
});
208209

209210
afterEach(() => {
@@ -358,6 +359,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
358359
additionalDirectories: [],
359360
turnDiffSummaries: [],
360361
activities: [],
362+
hydrated: true,
361363
});
362364

363365
expect(
@@ -394,6 +396,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
394396
additionalDirectories: [],
395397
turnDiffSummaries: [],
396398
activities: [],
399+
hydrated: true,
397400
});
398401

399402
expect(
@@ -436,6 +439,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
436439
additionalDirectories: [],
437440
turnDiffSummaries: [],
438441
activities: [],
442+
hydrated: true,
439443
});
440444

441445
expect(

apps/web/src/components/ChatView.logic.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export function buildLocalDraftThread(
5555
turnDiffSummaries: [],
5656
activities: [],
5757
proposedPlans: [],
58+
hydrated: true,
5859
};
5960
}
6061

apps/web/src/components/ChatView.tsx

Lines changed: 85 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -843,7 +843,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
843843
top: number;
844844
} | null>(null);
845845
const pendingInteractionAnchorFrameRef = useRef<number | null>(null);
846-
const lastContentHeightRef = useRef(0);
846+
const previousPhaseRef = useRef<ReturnType<typeof derivePhase>>("disconnected");
847+
const previousThreadIdRef = useRef<string | null>(null);
848+
const turnCompletionScrollUntilRef = useRef(0);
847849
const composerEditorRef = useRef<ComposerPromptEditorHandle>(null);
848850
const composerFormRef = useRef<HTMLFormElement>(null);
849851
const composerFormHeightRef = useRef(0);
@@ -2299,18 +2301,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
22992301

23002302
// Auto-scroll on new messages
23012303
const messageCount = timelineMessages.length;
2302-
const scrollMessagesToBottom = useCallback(
2303-
(behavior: ScrollBehavior = "auto", { enableAutoScroll = false } = {}) => {
2304-
const scrollContainer = messagesScrollRef.current;
2305-
if (!scrollContainer) return;
2306-
scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior });
2307-
lastKnownScrollTopRef.current = scrollContainer.scrollTop;
2308-
if (enableAutoScroll) {
2309-
shouldAutoScrollRef.current = true;
2310-
}
2311-
},
2312-
[],
2313-
);
2304+
const hasMessages = messageCount > 0;
2305+
const scrollMessagesToBottom = useCallback((behavior: ScrollBehavior = "auto") => {
2306+
const scrollContainer = messagesScrollRef.current;
2307+
if (!scrollContainer) return;
2308+
scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior });
2309+
lastKnownScrollTopRef.current = scrollContainer.scrollTop;
2310+
shouldAutoScrollRef.current = true;
2311+
}, []);
23142312
const cancelPendingStickToBottom = useCallback(() => {
23152313
const pendingFrame = pendingAutoScrollFrameRef.current;
23162314
if (pendingFrame === null) return;
@@ -2325,8 +2323,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
23252323
}, []);
23262324
const scheduleStickToBottom = useCallback(() => {
23272325
if (pendingAutoScrollFrameRef.current !== null) return;
2326+
if (performance.now() < turnCompletionScrollUntilRef.current) return;
23282327
pendingAutoScrollFrameRef.current = window.requestAnimationFrame(() => {
23292328
pendingAutoScrollFrameRef.current = null;
2329+
if (performance.now() < turnCompletionScrollUntilRef.current) return;
23302330
if (!shouldAutoScrollRef.current) return;
23312331
if (pendingUserScrollUpIntentRef.current || isPointerScrollActiveRef.current) return;
23322332
scrollMessagesToBottom();
@@ -2376,33 +2376,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
23762376
scrollMessagesToBottom();
23772377
scheduleStickToBottom();
23782378
}, [cancelPendingStickToBottom, scheduleStickToBottom, scrollMessagesToBottom]);
2379-
const REVEAL_SCROLL_VIEWPORT_FRACTION = 0.4;
2380-
const onRevealStart = useCallback(
2381-
(messageId: string) => {
2382-
const scrollContainer = messagesScrollRef.current;
2383-
if (!scrollContainer || !shouldAutoScrollRef.current) return;
2384-
if (!isScrollContainerNearBottom(scrollContainer)) return;
2385-
2386-
const rowElement = scrollContainer.querySelector<HTMLElement>(
2387-
`[data-row-message-id="${CSS.escape(messageId)}"]`,
2388-
);
2389-
if (!rowElement) return;
2390-
2391-
const rowHeight = rowElement.getBoundingClientRect().height;
2392-
const viewportHeight = scrollContainer.clientHeight;
2393-
if (rowHeight < viewportHeight * REVEAL_SCROLL_VIEWPORT_FRACTION) return;
2394-
2395-
cancelPendingStickToBottom();
2396-
pendingAutoScrollFrameRef.current = null;
2397-
2398-
const rowOffsetTop = rowElement.offsetTop;
2399-
scrollContainer.scrollTo({ top: rowOffsetTop, behavior: "smooth" });
2400-
lastKnownScrollTopRef.current = scrollContainer.scrollTop;
2401-
shouldAutoScrollRef.current = false;
2402-
setShowScrollToBottom(true);
2403-
},
2404-
[cancelPendingStickToBottom],
2405-
);
24062379
const onMessagesScroll = useCallback(() => {
24072380
const scrollContainer = messagesScrollRef.current;
24082381
if (!scrollContainer) return;
@@ -2414,18 +2387,17 @@ export default function ChatView({ threadId }: ChatViewProps) {
24142387
pendingUserScrollUpIntentRef.current = false;
24152388
} else if (shouldAutoScrollRef.current && pendingUserScrollUpIntentRef.current) {
24162389
const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1;
2417-
if (scrolledUp) {
2390+
if (scrolledUp && !isNearBottom) {
24182391
shouldAutoScrollRef.current = false;
2419-
pendingUserScrollUpIntentRef.current = false;
2420-
} else if (!scrolledUp) {
2421-
pendingUserScrollUpIntentRef.current = false;
24222392
}
2393+
pendingUserScrollUpIntentRef.current = false;
24232394
} else if (shouldAutoScrollRef.current && isPointerScrollActiveRef.current) {
24242395
const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1;
2425-
if (scrolledUp) {
2396+
if (scrolledUp && !isNearBottom) {
24262397
shouldAutoScrollRef.current = false;
24272398
}
24282399
} else if (shouldAutoScrollRef.current && !isNearBottom) {
2400+
// Catch-all for keyboard/assistive scroll interactions.
24292401
const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1;
24302402
if (scrolledUp) {
24312403
shouldAutoScrollRef.current = false;
@@ -2438,10 +2410,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
24382410
const onMessagesWheel = useCallback((event: React.WheelEvent<HTMLDivElement>) => {
24392411
if (event.deltaY < 0) {
24402412
pendingUserScrollUpIntentRef.current = true;
2441-
const scrollContainer = messagesScrollRef.current;
2442-
if (scrollContainer) {
2443-
scrollContainer.scrollTo({ top: scrollContainer.scrollTop });
2444-
}
24452413
}
24462414
}, []);
24472415
const onMessagesPointerDown = useCallback((_event: React.PointerEvent<HTMLDivElement>) => {
@@ -2479,18 +2447,17 @@ export default function ChatView({ threadId }: ChatViewProps) {
24792447
useLayoutEffect(() => {
24802448
if (!activeThread?.id) return;
24812449
shouldAutoScrollRef.current = true;
2482-
scrollMessagesToBottom();
2483-
const delays = [50, 150, 300];
2484-
const timeouts = delays.map((delay) =>
2485-
window.setTimeout(() => {
2486-
if (!shouldAutoScrollRef.current) return;
2487-
scrollMessagesToBottom();
2488-
}, delay),
2489-
);
2450+
scheduleStickToBottom();
2451+
const timeout = window.setTimeout(() => {
2452+
const scrollContainer = messagesScrollRef.current;
2453+
if (!scrollContainer) return;
2454+
if (isScrollContainerNearBottom(scrollContainer)) return;
2455+
scheduleStickToBottom();
2456+
}, 96);
24902457
return () => {
2491-
for (const timeout of timeouts) window.clearTimeout(timeout);
2458+
window.clearTimeout(timeout);
24922459
};
2493-
}, [activeThread?.id, scrollMessagesToBottom]);
2460+
}, [activeThread?.id, scheduleStickToBottom]);
24942461
useLayoutEffect(() => {
24952462
const composerForm = composerFormRef.current;
24962463
if (!composerForm) return;
@@ -2574,34 +2541,74 @@ export default function ChatView({ threadId }: ChatViewProps) {
25742541
if (!shouldAutoScrollRef.current) return;
25752542
scheduleStickToBottom();
25762543
}, [phase, scheduleStickToBottom, timelineEntries]);
2577-
useLayoutEffect(() => {
2578-
if (!messagesScrollElement || typeof ResizeObserver === "undefined") return;
2579-
const contentElement = messagesScrollElement.firstElementChild as HTMLElement | null;
2580-
if (!contentElement) return;
2544+
useEffect(() => {
2545+
const scrollContainer = messagesScrollRef.current;
2546+
if (!scrollContainer) return;
2547+
if (typeof ResizeObserver === "undefined") return;
2548+
2549+
const timelineRoot = scrollContainer.querySelector<HTMLElement>('[data-timeline-root="true"]');
2550+
if (!timelineRoot) return;
25812551

2582-
lastContentHeightRef.current = contentElement.getBoundingClientRect().height;
2552+
let previousHeight = timelineRoot.getBoundingClientRect().height;
25832553

25842554
const observer = new ResizeObserver((entries) => {
2585-
const [entry] = entries;
2555+
const entry = entries[0];
25862556
if (!entry) return;
2557+
25872558
const nextHeight = entry.contentRect.height;
2588-
const previousHeight = lastContentHeightRef.current;
2589-
lastContentHeightRef.current = nextHeight;
2559+
if (nextHeight <= previousHeight + 0.5) {
2560+
previousHeight = nextHeight;
2561+
return;
2562+
}
2563+
previousHeight = nextHeight;
25902564

2591-
if (nextHeight <= previousHeight) return;
25922565
if (!shouldAutoScrollRef.current) return;
2593-
if (pendingInteractionAnchorRef.current) return;
2594-
if (pendingUserScrollUpIntentRef.current || isPointerScrollActiveRef.current) return;
2595-
cancelPendingStickToBottom();
2596-
pendingAutoScrollFrameRef.current = null;
2597-
scrollMessagesToBottom();
2566+
scheduleStickToBottom();
25982567
});
25992568

2600-
observer.observe(contentElement);
2601-
return () => {
2602-
observer.disconnect();
2569+
observer.observe(timelineRoot);
2570+
return () => observer.disconnect();
2571+
}, [activeThread?.id, hasMessages, scheduleStickToBottom]);
2572+
useEffect(() => {
2573+
if (phase !== "running") return;
2574+
const intervalId = window.setInterval(() => {
2575+
if (!shouldAutoScrollRef.current) return;
2576+
const scrollContainer = messagesScrollRef.current;
2577+
if (!scrollContainer) return;
2578+
if (!isScrollContainerNearBottom(scrollContainer)) {
2579+
scrollMessagesToBottom();
2580+
}
2581+
}, 150);
2582+
return () => window.clearInterval(intervalId);
2583+
}, [phase, scrollMessagesToBottom]);
2584+
useEffect(() => {
2585+
const prevPhase = previousPhaseRef.current;
2586+
const prevThreadId = previousThreadIdRef.current;
2587+
previousPhaseRef.current = phase;
2588+
previousThreadIdRef.current = activeThreadId;
2589+
if (prevThreadId !== activeThreadId) return;
2590+
if (prevPhase !== "running" || phase === "running") return;
2591+
if (!shouldAutoScrollRef.current) return;
2592+
const assistantMessageId = activeLatestTurn?.assistantMessageId;
2593+
if (!assistantMessageId) return;
2594+
const scrollContainer = messagesScrollRef.current;
2595+
if (!scrollContainer) return;
2596+
const selector = `[data-row-message-id="${CSS.escape(assistantMessageId)}"]`;
2597+
turnCompletionScrollUntilRef.current = performance.now() + 1200;
2598+
cancelPendingStickToBottom();
2599+
let attempts = 0;
2600+
const tryScroll = () => {
2601+
const messageRow = scrollContainer.querySelector<HTMLElement>(selector);
2602+
if (messageRow) {
2603+
messageRow.scrollIntoView({ block: "start", behavior: "smooth" });
2604+
return;
2605+
}
2606+
if (++attempts < 10) {
2607+
requestAnimationFrame(tryScroll);
2608+
}
26032609
};
2604-
}, [messagesScrollElement, activeThread?.id, cancelPendingStickToBottom, scrollMessagesToBottom]);
2610+
requestAnimationFrame(tryScroll);
2611+
}, [activeLatestTurn?.assistantMessageId, activeThreadId, cancelPendingStickToBottom, phase]);
26052612

26062613
useEffect(() => {
26072614
setExpandedWorkGroups({});
@@ -4742,7 +4749,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
47424749
onCancelEditUserMessage={discardUserMessageEditSession}
47434750
onSubmitEditUserMessage={onSubmitEditUserMessage}
47444751
onReplyToSelection={onReplyToSelection}
4745-
onRevealStart={onRevealStart}
47464752
/>
47474753
</div>
47484754

@@ -4751,7 +4757,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
47514757
<div className="pointer-events-none absolute bottom-1 left-1/2 z-30 flex -translate-x-1/2 justify-center py-1.5">
47524758
<button
47534759
type="button"
4754-
onClick={() => scrollMessagesToBottom("smooth", { enableAutoScroll: true })}
4760+
onClick={() => scrollMessagesToBottom("smooth")}
47554761
className="pointer-events-auto flex items-center gap-1.5 rounded-full border border-border/60 bg-card px-3 py-1 text-muted-foreground text-xs shadow-sm transition-colors hover:border-border hover:text-foreground hover:cursor-pointer"
47564762
>
47574763
<ChevronDownIcon className="size-3.5" />

apps/web/src/components/Sidebar.logic.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -666,6 +666,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
666666
additionalDirectories: [],
667667
turnDiffSummaries: [],
668668
activities: [],
669+
hydrated: true,
669670
...overrides,
670671
};
671672
}

0 commit comments

Comments
 (0)