Skip to content

Commit 697bf52

Browse files
fix(cloud-agent): preserve chat scroll when viewing terminal (#3548)
* fix(cloud-agent): preserve chat scroll when viewing terminal * fix(cloud-agent): guard hidden chat scroll updates --------- Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com>
1 parent c03b149 commit 697bf52

1 file changed

Lines changed: 121 additions & 86 deletions

File tree

apps/web/src/components/cloud-agent-next/CloudChatPage.tsx

Lines changed: 121 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -80,38 +80,55 @@ const StaticMessages = memo(
8080
StaticMessages.displayName = 'StaticMessages';
8181

8282
// ---------------------------------------------------------------------------
83-
// Dynamic messages — re-renders as streaming progresses
83+
// Dynamic messages — re-renders as streaming progresses while chat is visible
8484
// ---------------------------------------------------------------------------
85-
function DynamicMessages({
86-
messages,
87-
pendingMessages,
88-
getChildMessages,
89-
onOpenChildSession,
90-
}: {
85+
type DynamicMessagesProps = {
86+
active: boolean;
9187
messages: StoredMessage[];
9288
pendingMessages: ReadonlyMap<string, MessageDeliveryState>;
9389
getChildMessages?: (sessionId: string) => StoredMessage[];
9490
onOpenChildSession?: OpenChildSession;
95-
}) {
96-
return (
97-
<>
98-
{messages.map(msg => {
99-
const streaming = isMessageStreaming(msg);
100-
return (
101-
<MessageErrorBoundary key={msg.info.id}>
102-
<MessageBubble
103-
message={msg}
104-
isStreaming={streaming}
105-
deliveryState={pendingMessages.get(msg.info.id)}
106-
getChildMessages={getChildMessages}
107-
onOpenChildSession={onOpenChildSession}
108-
/>
109-
</MessageErrorBoundary>
110-
);
111-
})}
112-
</>
113-
);
114-
}
91+
};
92+
93+
const DynamicMessages = memo(
94+
function DynamicMessages({
95+
messages,
96+
pendingMessages,
97+
getChildMessages,
98+
onOpenChildSession,
99+
}: DynamicMessagesProps) {
100+
return (
101+
<>
102+
{messages.map(msg => {
103+
const streaming = isMessageStreaming(msg);
104+
return (
105+
<MessageErrorBoundary key={msg.info.id}>
106+
<MessageBubble
107+
message={msg}
108+
isStreaming={streaming}
109+
deliveryState={pendingMessages.get(msg.info.id)}
110+
getChildMessages={getChildMessages}
111+
onOpenChildSession={onOpenChildSession}
112+
/>
113+
</MessageErrorBoundary>
114+
);
115+
})}
116+
</>
117+
);
118+
},
119+
(previous, next) => {
120+
if (!previous.active && !next.active) return true;
121+
122+
return (
123+
previous.active === next.active &&
124+
previous.messages === next.messages &&
125+
previous.pendingMessages === next.pendingMessages &&
126+
previous.getChildMessages === next.getChildMessages &&
127+
previous.onOpenChildSession === next.onOpenChildSession
128+
);
129+
}
130+
);
131+
DynamicMessages.displayName = 'DynamicMessages';
115132

116133
// ---------------------------------------------------------------------------
117134
// CloudChatPage
@@ -141,7 +158,7 @@ function TerminalPaneSlot({
141158
);
142159

143160
return (
144-
<div className={active ? 'h-full min-h-0' : 'hidden'} aria-hidden={!active}>
161+
<div className={active ? 'h-full min-h-0' : 'hidden'}>
145162
{sessionId && (
146163
<CloudAgentTerminalPane
147164
cloudAgentSessionId={sessionId}
@@ -209,6 +226,7 @@ export default function CloudChatPage({ organizationId }: CloudChatPageProps) {
209226
const [terminalStatuses, setTerminalStatuses] = useState<
210227
Record<string, TerminalStatusSummary | undefined>
211228
>({});
229+
const chatTabActive = workspaceTabs.activeTabId === CHAT_TAB_ID;
212230

213231
useEffect(() => {
214232
setWorkspaceTabs(resetWorkspaceTabs);
@@ -247,11 +265,23 @@ export default function CloudChatPage({ organizationId }: CloudChatPageProps) {
247265
const lastScrollTopRef = useRef(0);
248266

249267
const autoScrollFrameRef = useRef(0);
268+
const followUpAutoScrollFrameRef = useRef(0);
250269
const delayedAutoScrollRef = useRef<ReturnType<typeof setTimeout> | null>(null);
251270

271+
const cancelScheduledAutoScroll = useCallback(() => {
272+
cancelAnimationFrame(autoScrollFrameRef.current);
273+
cancelAnimationFrame(followUpAutoScrollFrameRef.current);
274+
autoScrollFrameRef.current = 0;
275+
followUpAutoScrollFrameRef.current = 0;
276+
if (delayedAutoScrollRef.current !== null) {
277+
clearTimeout(delayedAutoScrollRef.current);
278+
delayedAutoScrollRef.current = null;
279+
}
280+
}, []);
281+
252282
const scrollToBottomNow = useCallback(() => {
253283
const el = scrollContainerRef.current;
254-
if (!el) return;
284+
if (!el || el.hidden) return;
255285

256286
const scrollRun = autoScrollRunRef.current + 1;
257287
autoScrollRunRef.current = scrollRun;
@@ -268,38 +298,41 @@ export default function CloudChatPage({ organizationId }: CloudChatPageProps) {
268298
}, []);
269299

270300
const scheduleScrollToBottom = useCallback(() => {
271-
cancelAnimationFrame(autoScrollFrameRef.current);
272-
if (delayedAutoScrollRef.current !== null) {
273-
clearTimeout(delayedAutoScrollRef.current);
274-
delayedAutoScrollRef.current = null;
275-
}
301+
cancelScheduledAutoScroll();
276302

277303
autoScrollFrameRef.current = requestAnimationFrame(() => {
304+
autoScrollFrameRef.current = 0;
278305
scrollToBottomNow();
279-
requestAnimationFrame(scrollToBottomNow);
306+
followUpAutoScrollFrameRef.current = requestAnimationFrame(() => {
307+
followUpAutoScrollFrameRef.current = 0;
308+
scrollToBottomNow();
309+
});
280310
delayedAutoScrollRef.current = setTimeout(() => {
281311
delayedAutoScrollRef.current = null;
282312
scrollToBottomNow();
283313
}, 100);
284314
});
285-
}, [scrollToBottomNow]);
315+
}, [cancelScheduledAutoScroll, scrollToBottomNow]);
316+
317+
useEffect(() => cancelScheduledAutoScroll, [cancelScheduledAutoScroll]);
286318

287319
useEffect(() => {
288-
return () => {
289-
cancelAnimationFrame(autoScrollFrameRef.current);
290-
if (delayedAutoScrollRef.current !== null) {
291-
clearTimeout(delayedAutoScrollRef.current);
292-
}
293-
};
294-
}, []);
320+
if (!chatTabActive) cancelScheduledAutoScroll();
321+
}, [cancelScheduledAutoScroll, chatTabActive]);
295322

296323
useEffect(() => {
297-
if (!chatUI.shouldAutoScroll) return;
324+
if (!chatTabActive || !chatUI.shouldAutoScroll) return;
298325
scheduleScrollToBottom();
299-
}, [staticMessages, dynamicMessages, chatUI.shouldAutoScroll, scheduleScrollToBottom]);
326+
}, [
327+
staticMessages,
328+
dynamicMessages,
329+
chatTabActive,
330+
chatUI.shouldAutoScroll,
331+
scheduleScrollToBottom,
332+
]);
300333

301334
useEffect(() => {
302-
if (!chatUI.shouldAutoScroll) return;
335+
if (!chatTabActive || !chatUI.shouldAutoScroll) return;
303336
if (typeof ResizeObserver === 'undefined') return;
304337

305338
const content = messagesContentRef.current;
@@ -310,7 +343,7 @@ export default function CloudChatPage({ organizationId }: CloudChatPageProps) {
310343
});
311344
observer.observe(content);
312345
return () => observer.disconnect();
313-
}, [chatUI.shouldAutoScroll, scheduleScrollToBottom]);
346+
}, [chatTabActive, chatUI.shouldAutoScroll, scheduleScrollToBottom]);
314347

315348
useEffect(() => {
316349
if (!sessionIdFromParams) return;
@@ -670,54 +703,56 @@ export default function CloudChatPage({ organizationId }: CloudChatPageProps) {
670703
className="flex min-h-0 flex-1 flex-col"
671704
>
672705
<div className="relative min-h-0 flex-1">
673-
{workspaceTabs.activeTabId === CHAT_TAB_ID && (
674-
<>
675-
<div
676-
ref={scrollContainerRef}
677-
className={`absolute inset-0 overflow-y-auto px-[max(1rem,calc(50%_-_27rem))] pb-2 pt-4 transition-opacity duration-150 ${showLoadingIndicator ? 'pointer-events-none opacity-40' : 'opacity-100'}`}
678-
onScroll={handleScroll}
679-
>
680-
<div ref={messagesContentRef}>
681-
<StaticMessages
682-
messages={staticMessages}
683-
pendingMessages={pendingMessages}
684-
getChildMessages={getChildMessages}
685-
onOpenChildSession={handleOpenTopLevelChildSession}
686-
/>
687-
<DynamicMessages
688-
messages={dynamicMessages}
689-
pendingMessages={pendingMessages}
690-
getChildMessages={getChildMessages}
691-
onOpenChildSession={handleOpenTopLevelChildSession}
692-
/>
693-
706+
<>
707+
<div
708+
ref={scrollContainerRef}
709+
hidden={!chatTabActive}
710+
className={`absolute inset-0 overflow-y-auto px-[max(1rem,calc(50%_-_27rem))] pb-2 pt-4 transition-opacity duration-150 ${showLoadingIndicator ? 'pointer-events-none opacity-40' : 'opacity-100'}`}
711+
onScroll={handleScroll}
712+
>
713+
<div ref={messagesContentRef}>
714+
<StaticMessages
715+
messages={staticMessages}
716+
pendingMessages={pendingMessages}
717+
getChildMessages={getChildMessages}
718+
onOpenChildSession={handleOpenTopLevelChildSession}
719+
/>
720+
<DynamicMessages
721+
active={chatTabActive}
722+
messages={dynamicMessages}
723+
pendingMessages={pendingMessages}
724+
getChildMessages={getChildMessages}
725+
onOpenChildSession={handleOpenTopLevelChildSession}
726+
/>
727+
728+
{chatTabActive && (
694729
<WorkingIndicator
695730
messages={dynamicMessages}
696731
isStreaming={isStreaming}
697732
/>
698-
{statusIndicator && (
699-
<SessionStatusIndicator indicator={statusIndicator} />
700-
)}
733+
)}
734+
{statusIndicator && (
735+
<SessionStatusIndicator indicator={statusIndicator} />
736+
)}
701737

702-
<div ref={messagesEndRef} />
703-
</div>
738+
<div ref={messagesEndRef} />
704739
</div>
740+
</div>
705741

706-
{showScrollButton && (
707-
<button
708-
type="button"
709-
onClick={scrollToBottom}
710-
className="border-border bg-background absolute bottom-4 left-1/2 -translate-x-1/2 rounded-full border p-2 shadow-md"
711-
>
712-
<ArrowDown className="h-4 w-4" />
713-
</button>
714-
)}
715-
</>
716-
)}
742+
{chatTabActive && showScrollButton && (
743+
<button
744+
type="button"
745+
onClick={scrollToBottom}
746+
className="border-border bg-background absolute bottom-4 left-1/2 -translate-x-1/2 rounded-full border p-2 shadow-md"
747+
>
748+
<ArrowDown className="h-4 w-4" />
749+
</button>
750+
)}
751+
</>
717752

718753
<div
719754
className={
720-
workspaceTabs.activeTabId === CHAT_TAB_ID
755+
chatTabActive
721756
? 'hidden'
722757
: 'h-full min-h-0 px-[max(1rem,calc(50%_-_27rem))] py-2'
723758
}
@@ -726,7 +761,7 @@ export default function CloudChatPage({ organizationId }: CloudChatPageProps) {
726761
</div>
727762
</div>
728763

729-
{workspaceTabs.activeTabId === CHAT_TAB_ID && (
764+
{chatTabActive && (
730765
<>
731766
{isReadOnly ? (
732767
!isLoading && sessionIdFromParams && fetchedSessionData ? (

0 commit comments

Comments
 (0)