Skip to content

Commit c46372e

Browse files
committed
Standardize scrolling on TanStack Virtual API
1 parent 96e3fbb commit c46372e

2 files changed

Lines changed: 45 additions & 29 deletions

File tree

apps/twig/src/renderer/features/sessions/components/ConversationView.tsx

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -90,16 +90,27 @@ export function ConversationView({
9090
const { saveScrollPosition, getScrollPosition } = useSessionViewActions();
9191

9292
const [showScrollButton, setShowScrollButton] = useState(false);
93+
const showScrollButtonRef = useRef(false);
9394
const hasRestoredScrollRef = useRef(false);
9495
const prevItemCountRef = useRef(0);
96+
const prevPendingCountRef = useRef(0);
97+
const prevEventsLengthRef = useRef(events.length);
9598

9699
const queuedItems = useMemo<QueuedItem[]>(
97-
() => queuedMessages.map((msg) => ({ type: "queued" as const, id: msg.id, message: msg })),
100+
() =>
101+
queuedMessages.map((msg) => ({
102+
type: "queued" as const,
103+
id: msg.id,
104+
message: msg,
105+
})),
98106
[queuedMessages],
99107
);
100108

101109
const virtualizedItems = useMemo<VirtualizedItem[]>(
102-
() => (queuedItems.length > 0 ? [...conversationItems, ...queuedItems] : conversationItems),
110+
() =>
111+
queuedItems.length > 0
112+
? [...conversationItems, ...queuedItems]
113+
: conversationItems,
103114
[conversationItems, queuedItems],
104115
);
105116

@@ -108,35 +119,37 @@ export function ConversationView({
108119

109120
const savedPosition = getScrollPosition(taskId);
110121
if (savedPosition > 0) {
111-
const virtualizer = listRef.current?.getVirtualizer();
112-
if (virtualizer) {
113-
virtualizer.scrollOffset = savedPosition;
114-
hasRestoredScrollRef.current = true;
115-
}
122+
listRef.current?.scrollToOffset(savedPosition);
123+
hasRestoredScrollRef.current = true;
116124
}
117125
}, [taskId, getScrollPosition]);
118126

119-
const isStreaming = lastTurn && !lastTurn.isComplete;
120-
121127
useEffect(() => {
122128
const isNewContent = virtualizedItems.length > prevItemCountRef.current;
129+
const isNewPending = pendingPermissionsCount > prevPendingCountRef.current;
130+
const isNewEvents = events.length > prevEventsLengthRef.current;
123131
prevItemCountRef.current = virtualizedItems.length;
132+
prevPendingCountRef.current = pendingPermissionsCount;
133+
prevEventsLengthRef.current = events.length;
124134

125-
if (isNewContent && !showScrollButton) {
135+
// Always force-scroll for new items or new permissions (needs attention)
136+
if (isNewContent || isNewPending) {
126137
listRef.current?.scrollToBottom();
138+
return;
127139
}
128-
}, [virtualizedItems.length, showScrollButton]);
129140

130-
useEffect(() => {
131-
if (isStreaming && !showScrollButton) {
141+
// For streaming content growth, only scroll if user hasn't scrolled up
142+
if (isNewEvents && !showScrollButtonRef.current) {
132143
listRef.current?.scrollToBottom();
133144
}
134-
}, [isStreaming, showScrollButton]);
145+
}, [events.length, virtualizedItems.length, pendingPermissionsCount]);
135146

136147
const handleScroll = useCallback(
137148
(scrollOffset: number, scrollHeight: number, clientHeight: number) => {
138149
const distanceFromBottom = scrollHeight - scrollOffset - clientHeight;
139-
setShowScrollButton(distanceFromBottom > SHOW_BUTTON_THRESHOLD);
150+
const isScrolledUp = distanceFromBottom > SHOW_BUTTON_THRESHOLD;
151+
showScrollButtonRef.current = isScrolledUp;
152+
setShowScrollButton(isScrolledUp);
140153

141154
if (taskId) {
142155
saveScrollPosition(taskId, scrollOffset);

apps/twig/src/renderer/features/sessions/components/VirtualizedList.tsx

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import {
22
type ScrollToOptions,
33
useVirtualizer,
4-
type Virtualizer,
54
type VirtualizerOptions,
65
} from "@tanstack/react-virtual";
76
import {
@@ -37,9 +36,9 @@ interface VirtualizedListProps<T> {
3736

3837
export interface VirtualizedListHandle {
3938
scrollToIndex: (index: number, options?: ScrollToOptions) => void;
39+
scrollToOffset: (offset: number, options?: ScrollToOptions) => void;
4040
scrollToBottom: () => void;
4141
measure: () => void;
42-
getVirtualizer: () => Virtualizer<HTMLDivElement, Element>;
4342
}
4443

4544
function VirtualizedListInner<T>(
@@ -75,18 +74,22 @@ function VirtualizedListInner<T>(
7574
: undefined,
7675
});
7776

78-
useImperativeHandle(ref, () => ({
79-
scrollToIndex: (index, options) =>
80-
virtualizer.scrollToIndex(index, options),
81-
scrollToBottom: () => {
82-
const el = scrollRef.current;
83-
if (el) {
84-
el.scrollTop = el.scrollHeight;
85-
}
86-
},
87-
measure: () => virtualizer.measure(),
88-
getVirtualizer: () => virtualizer,
89-
}));
77+
useImperativeHandle(
78+
ref,
79+
() => ({
80+
scrollToIndex: (index, options) =>
81+
virtualizer.scrollToIndex(index, options),
82+
scrollToOffset: (offset, options) =>
83+
virtualizer.scrollToOffset(offset, options),
84+
scrollToBottom: () => {
85+
if (items.length > 0) {
86+
virtualizer.scrollToIndex(items.length - 1, { align: "end" });
87+
}
88+
},
89+
measure: () => virtualizer.measure(),
90+
}),
91+
[virtualizer, items.length],
92+
);
9093

9194
useEffect(() => {
9295
if (autoScrollToBottom && items.length > 0) {

0 commit comments

Comments
 (0)