diff --git a/apps/array/src/renderer/features/sessions/components/ConversationView.tsx b/apps/array/src/renderer/features/sessions/components/ConversationView.tsx index 5dd9e917..ffd05c7e 100644 --- a/apps/array/src/renderer/features/sessions/components/ConversationView.tsx +++ b/apps/array/src/renderer/features/sessions/components/ConversationView.tsx @@ -3,8 +3,8 @@ import type { SessionNotification, } from "@agentclientprotocol/sdk"; import type { SessionUpdate, ToolCall } from "@features/sessions/types"; -import { XCircle } from "@phosphor-icons/react"; -import { Box, Flex, Text } from "@radix-ui/themes"; +import { ArrowDown, XCircle } from "@phosphor-icons/react"; +import { Box, Button, Flex, Text } from "@radix-ui/themes"; import { type AcpMessage, isJsonRpcNotification, @@ -12,7 +12,14 @@ import { isJsonRpcResponse, type UserShellExecuteParams, } from "@shared/types/session-events"; -import { memo, useLayoutEffect, useMemo, useRef } from "react"; +import { + memo, + useCallback, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; import { GitActionMessage, parseGitActionMessage } from "./GitActionMessage"; import { GitActionResult } from "./GitActionResult"; import { SessionFooter } from "./SessionFooter"; @@ -47,6 +54,9 @@ interface ConversationViewProps { isCloud?: boolean; } +const SCROLL_THRESHOLD = 100; +const SHOW_BUTTON_THRESHOLD = 300; + export function ConversationView({ events, isPromptPending, @@ -57,43 +67,82 @@ export function ConversationView({ const items = useMemo(() => buildConversationItems(events), [events]); const lastTurn = items.filter((i): i is Turn => i.type === "turn").pop(); - // Scroll to bottom on initial mount - const hasScrolledRef = useRef(false); + const isNearBottomRef = useRef(true); + const prevItemsLengthRef = useRef(0); + const [showScrollButton, setShowScrollButton] = useState(false); + + // Update isNearBottom on scroll + useLayoutEffect(() => { + const el = scrollRef.current; + if (!el) return; + + const handleScroll = () => { + const distanceFromBottom = + el.scrollHeight - el.scrollTop - el.clientHeight; + isNearBottomRef.current = distanceFromBottom <= SCROLL_THRESHOLD; + setShowScrollButton(distanceFromBottom > SHOW_BUTTON_THRESHOLD); + }; + + el.addEventListener("scroll", handleScroll); + return () => el.removeEventListener("scroll", handleScroll); + }, []); + + // Scroll to bottom on first render and when new content arrives useLayoutEffect(() => { - if (hasScrolledRef.current) return; const el = scrollRef.current; - if (el && items.length > 0) { + if (!el) return; + + const isNewContent = items.length > prevItemsLengthRef.current; + prevItemsLengthRef.current = items.length; + + if (isNearBottomRef.current || isNewContent) { el.scrollTop = el.scrollHeight; - hasScrolledRef.current = true; } }, [items]); + const scrollToBottom = useCallback(() => { + const el = scrollRef.current; + if (el) { + el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); + } + }, []); + return ( -
-
- {items.map((item) => - item.type === "turn" ? ( - - ) : ( - - ), - )} +
+
+
+ {items.map((item) => + item.type === "turn" ? ( + + ) : ( + + ), + )} +
+
- + {showScrollButton && ( + + + + )}
); }