Skip to content

Commit 128a1e5

Browse files
authored
feat: have conversation view use virtualized list (#935)
1 parent e38a01a commit 128a1e5

File tree

2 files changed

+220
-127
lines changed

2 files changed

+220
-127
lines changed

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

Lines changed: 102 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type {
33
SessionNotification,
44
} from "@agentclientprotocol/sdk";
55
import {
6+
type QueuedMessage,
67
usePendingPermissionsForTask,
78
useQueuedMessagesForTask,
89
} from "@features/sessions/stores/sessionStore";
@@ -17,15 +18,7 @@ import {
1718
isJsonRpcResponse,
1819
type UserShellExecuteParams,
1920
} from "@shared/types/session-events";
20-
import {
21-
memo,
22-
useCallback,
23-
useEffect,
24-
useLayoutEffect,
25-
useMemo,
26-
useRef,
27-
useState,
28-
} from "react";
21+
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
2922
import { GitActionMessage, parseGitActionMessage } from "./GitActionMessage";
3023
import { GitActionResult } from "./GitActionResult";
3124
import { SessionFooter } from "./SessionFooter";
@@ -39,6 +32,7 @@ import {
3932
type UserShellExecute,
4033
UserShellExecuteView,
4134
} from "./session-update/UserShellExecuteView";
35+
import { VirtualizedList, type VirtualizedListHandle } from "./VirtualizedList";
4236

4337
interface Turn {
4438
type: "turn";
@@ -53,7 +47,14 @@ interface Turn {
5347
toolCalls: Map<string, ToolCall>;
5448
}
5549

50+
interface QueuedItem {
51+
type: "queued";
52+
id: string;
53+
message: QueuedMessage;
54+
}
55+
5656
type ConversationItem = Turn | UserShellExecute;
57+
type VirtualizedItem = ConversationItem | QueuedItem;
5758

5859
interface ConversationViewProps {
5960
events: AcpMessage[];
@@ -63,8 +64,8 @@ interface ConversationViewProps {
6364
taskId?: string;
6465
}
6566

66-
const SCROLL_THRESHOLD = 100;
6767
const SHOW_BUTTON_THRESHOLD = 300;
68+
const ESTIMATE_SIZE = 200;
6869

6970
export function ConversationView({
7071
events,
@@ -73,110 +74,125 @@ export function ConversationView({
7374
repoPath,
7475
taskId,
7576
}: ConversationViewProps) {
76-
const scrollRef = useRef<HTMLDivElement>(null);
77-
const items = useMemo(() => buildConversationItems(events), [events]);
78-
const lastTurn = items.filter((i): i is Turn => i.type === "turn").pop();
77+
const listRef = useRef<VirtualizedListHandle>(null);
78+
const conversationItems = useMemo(
79+
() => buildConversationItems(events),
80+
[events],
81+
);
82+
const lastTurn = conversationItems
83+
.filter((i): i is Turn => i.type === "turn")
84+
.pop();
7985

8086
const pendingPermissions = usePendingPermissionsForTask(taskId ?? "");
8187
const pendingPermissionsCount = pendingPermissions.size;
8288

8389
const queuedMessages = useQueuedMessagesForTask(taskId);
8490
const { saveScrollPosition, getScrollPosition } = useSessionViewActions();
8591

86-
const prevItemsLengthRef = useRef(0);
87-
const prevPendingCountRef = useRef(0);
88-
const prevScrollHeightRef = useRef(0);
8992
const [showScrollButton, setShowScrollButton] = useState(false);
9093
const hasRestoredScrollRef = useRef(false);
94+
const prevItemCountRef = useRef(0);
9195

92-
useEffect(() => {
93-
hasRestoredScrollRef.current = false;
94-
}, []);
95-
96-
useLayoutEffect(() => {
97-
const el = scrollRef.current;
98-
if (!el || !taskId) return;
96+
const virtualizedItems = useMemo<VirtualizedItem[]>(() => {
97+
const items: VirtualizedItem[] = [...conversationItems];
9998

100-
const handleScroll = () => {
101-
const distanceFromBottom =
102-
el.scrollHeight - el.scrollTop - el.clientHeight;
103-
setShowScrollButton(distanceFromBottom > SHOW_BUTTON_THRESHOLD);
104-
saveScrollPosition(taskId, el.scrollTop);
105-
};
99+
for (const msg of queuedMessages) {
100+
items.push({ type: "queued", id: msg.id, message: msg });
101+
}
106102

107-
el.addEventListener("scroll", handleScroll);
108-
return () => {
109-
el.removeEventListener("scroll", handleScroll);
110-
saveScrollPosition(taskId, el.scrollTop);
111-
};
112-
}, [taskId, saveScrollPosition]);
103+
return items;
104+
}, [conversationItems, queuedMessages]);
113105

114-
useLayoutEffect(() => {
115-
const el = scrollRef.current;
116-
if (!el || !taskId) return;
106+
useEffect(() => {
107+
if (!taskId || hasRestoredScrollRef.current) return;
117108

118-
if (!hasRestoredScrollRef.current) {
119-
const savedPosition = getScrollPosition(taskId);
120-
if (savedPosition > 0) {
121-
el.scrollTop = savedPosition;
109+
const savedPosition = getScrollPosition(taskId);
110+
if (savedPosition > 0) {
111+
const virtualizer = listRef.current?.getVirtualizer();
112+
if (virtualizer) {
113+
virtualizer.scrollOffset = savedPosition;
122114
hasRestoredScrollRef.current = true;
123-
return;
124115
}
125116
}
117+
}, [taskId, getScrollPosition]);
126118

127-
const isNewContent = items.length > prevItemsLengthRef.current;
128-
const isNewPending = pendingPermissionsCount > prevPendingCountRef.current;
129-
prevItemsLengthRef.current = items.length;
130-
prevPendingCountRef.current = pendingPermissionsCount;
119+
const isStreaming = lastTurn && !lastTurn.isComplete;
131120

132-
const prevScrollHeight = prevScrollHeightRef.current || el.scrollHeight;
133-
const wasNearBottom =
134-
prevScrollHeight - el.scrollTop - el.clientHeight <= SCROLL_THRESHOLD;
135-
prevScrollHeightRef.current = el.scrollHeight;
121+
useEffect(() => {
122+
const isNewContent = virtualizedItems.length > prevItemCountRef.current;
123+
prevItemCountRef.current = virtualizedItems.length;
136124

137-
if (wasNearBottom || isNewContent || isNewPending) {
138-
el.scrollTop = el.scrollHeight;
125+
if (isNewContent && !showScrollButton) {
126+
listRef.current?.scrollToBottom();
139127
}
140-
}, [items, pendingPermissionsCount, taskId, getScrollPosition]);
128+
}, [virtualizedItems.length, showScrollButton]);
141129

142-
const scrollToBottom = useCallback(() => {
143-
const el = scrollRef.current;
144-
if (el) {
145-
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
130+
useEffect(() => {
131+
if (isStreaming && !showScrollButton) {
132+
listRef.current?.scrollToBottom();
146133
}
134+
}, [isStreaming, showScrollButton]);
135+
136+
const handleScroll = useCallback(
137+
(scrollOffset: number, scrollHeight: number, clientHeight: number) => {
138+
const distanceFromBottom = scrollHeight - scrollOffset - clientHeight;
139+
setShowScrollButton(distanceFromBottom > SHOW_BUTTON_THRESHOLD);
140+
141+
if (taskId) {
142+
saveScrollPosition(taskId, scrollOffset);
143+
}
144+
},
145+
[taskId, saveScrollPosition],
146+
);
147+
148+
const scrollToBottom = useCallback(() => {
149+
listRef.current?.scrollToBottom();
147150
}, []);
148151

152+
const renderItem = useCallback(
153+
(item: VirtualizedItem) => {
154+
switch (item.type) {
155+
case "turn":
156+
return <TurnView turn={item} repoPath={repoPath} />;
157+
case "user_shell_execute":
158+
return <UserShellExecuteView item={item} />;
159+
case "queued":
160+
return <QueuedMessageView message={item.message} />;
161+
}
162+
},
163+
[repoPath],
164+
);
165+
166+
const getItemKey = useCallback((item: VirtualizedItem) => item.id, []);
167+
149168
return (
150169
<div className="relative flex-1">
151-
<div
152-
ref={scrollRef}
153-
className="absolute inset-0 overflow-auto bg-gray-1 p-2 pb-16"
154-
>
155-
<div className="mx-auto max-w-[750px]">
156-
<div className="flex flex-col gap-3">
157-
{items.map((item) =>
158-
item.type === "turn" ? (
159-
<TurnView key={item.id} turn={item} repoPath={repoPath} />
160-
) : (
161-
<UserShellExecuteView key={item.id} item={item} />
162-
),
163-
)}
164-
{queuedMessages.map((msg) => (
165-
<QueuedMessageView key={msg.id} message={msg} />
166-
))}
170+
<VirtualizedList
171+
ref={listRef}
172+
items={virtualizedItems}
173+
estimateSize={ESTIMATE_SIZE}
174+
gap={12}
175+
overscan={5}
176+
getItemKey={getItemKey}
177+
renderItem={renderItem}
178+
onScroll={handleScroll}
179+
className="absolute inset-0 bg-gray-1 p-2"
180+
innerClassName="mx-auto max-w-[750px]"
181+
footer={
182+
<div className="pb-16">
183+
<SessionFooter
184+
isPromptPending={isPromptPending}
185+
promptStartedAt={promptStartedAt}
186+
lastGenerationDuration={
187+
lastTurn?.isComplete ? lastTurn.durationMs : null
188+
}
189+
lastStopReason={lastTurn?.stopReason}
190+
queuedCount={queuedMessages.length}
191+
hasPendingPermission={pendingPermissionsCount > 0}
192+
/>
167193
</div>
168-
<SessionFooter
169-
isPromptPending={isPromptPending}
170-
promptStartedAt={promptStartedAt}
171-
lastGenerationDuration={
172-
lastTurn?.isComplete ? lastTurn.durationMs : null
173-
}
174-
lastStopReason={lastTurn?.stopReason}
175-
queuedCount={queuedMessages.length}
176-
hasPendingPermission={pendingPermissionsCount > 0}
177-
/>
178-
</div>
179-
</div>
194+
}
195+
/>
180196
{showScrollButton && (
181197
<Box className="absolute right-4 bottom-4 z-10">
182198
<Button size="1" variant="solid" onClick={scrollToBottom}>

0 commit comments

Comments
 (0)