|
1 | 1 | import { cn } from "@hypr/utils"; |
2 | | -import { DependencyList, useLayoutEffect, useRef } from "react"; |
| 2 | +import { DependencyList, useEffect, useLayoutEffect, useRef, useState } from "react"; |
| 3 | + |
| 4 | +import { useListener } from "../../../../../../../contexts/listener"; |
3 | 5 | import { useSegments } from "./segment"; |
4 | 6 |
|
5 | 7 | export function TranscriptViewer({ sessionId }: { sessionId: string }) { |
6 | 8 | const segments = useSegments(sessionId); |
7 | | - return <Renderer segments={segments} />; |
| 9 | + return <Renderer segments={segments} sessionId={sessionId} />; |
8 | 10 | } |
9 | 11 |
|
10 | | -function Renderer({ segments }: { segments: ReturnType<typeof useSegments> }) { |
11 | | - const containerRef = useAutoScroll<HTMLDivElement>([segments]); |
| 12 | +function Renderer({ segments, sessionId }: { segments: ReturnType<typeof useSegments>; sessionId: string }) { |
| 13 | + const { containerRef, isAtBottom, scrollToBottom } = useScrollToBottom([segments]); |
| 14 | + const active = useListener((state) => state.status === "running_active" && state.sessionId === sessionId); |
12 | 15 |
|
13 | 16 | if (segments.length === 0) { |
14 | 17 | return null; |
15 | 18 | } |
16 | 19 |
|
17 | 20 | return ( |
18 | | - <div |
19 | | - ref={containerRef} |
20 | | - className={cn([ |
21 | | - "space-y-8 h-full overflow-y-auto overflow-x-hidden", |
22 | | - "px-0.5 pb-32 scroll-pb-[8rem]", |
23 | | - ])} |
24 | | - > |
25 | | - {segments.map( |
26 | | - (segment, i) => <Segment key={i} segment={segment} />, |
| 21 | + <div className="relative h-full"> |
| 22 | + <div |
| 23 | + ref={containerRef} |
| 24 | + className={cn([ |
| 25 | + "space-y-8 h-full overflow-y-auto overflow-x-hidden", |
| 26 | + "px-0.5 pb-16 scroll-pb-[8rem]", |
| 27 | + true ? "scrollbar-none" : "scroll-pb-[4rem]", |
| 28 | + ])} |
| 29 | + > |
| 30 | + {segments.map( |
| 31 | + (segment, i) => <Segment key={i} segment={segment} />, |
| 32 | + )} |
| 33 | + </div> |
| 34 | + |
| 35 | + {(!isAtBottom && active) && ( |
| 36 | + <button |
| 37 | + onClick={scrollToBottom} |
| 38 | + className={cn([ |
| 39 | + "absolute bottom-3 left-1/2 -translate-x-1/2", |
| 40 | + "px-4 py-2 rounded-full", |
| 41 | + "shadow-lg bg-neutral-800 hover:bg-neutral-700", |
| 42 | + "text-white text-xs font-light", |
| 43 | + "transition-all duration-200", |
| 44 | + "z-30", |
| 45 | + ])} |
| 46 | + > |
| 47 | + Go to bottom |
| 48 | + </button> |
27 | 49 | )} |
28 | 50 | </div> |
29 | 51 | ); |
@@ -69,6 +91,37 @@ function Segment({ segment }: { segment: ReturnType<typeof useSegments>[number] |
69 | 91 | ); |
70 | 92 | } |
71 | 93 |
|
| 94 | +function useScrollToBottom(deps: DependencyList) { |
| 95 | + const containerRef = useAutoScroll<HTMLDivElement>(deps); |
| 96 | + const [isAtBottom, setIsAtBottom] = useState(true); |
| 97 | + |
| 98 | + useEffect(() => { |
| 99 | + const element = containerRef.current; |
| 100 | + if (!element) { |
| 101 | + return; |
| 102 | + } |
| 103 | + |
| 104 | + const handleScroll = () => { |
| 105 | + const threshold = 100; |
| 106 | + const isNearBottom = element.scrollHeight - element.scrollTop - element.clientHeight < threshold; |
| 107 | + setIsAtBottom(isNearBottom); |
| 108 | + }; |
| 109 | + |
| 110 | + element.addEventListener("scroll", handleScroll); |
| 111 | + return () => element.removeEventListener("scroll", handleScroll); |
| 112 | + }, []); |
| 113 | + |
| 114 | + const scrollToBottom = () => { |
| 115 | + const element = containerRef.current; |
| 116 | + if (!element) { |
| 117 | + return; |
| 118 | + } |
| 119 | + element.scrollTo({ top: element.scrollHeight, behavior: "smooth" }); |
| 120 | + }; |
| 121 | + |
| 122 | + return { containerRef, isAtBottom, scrollToBottom }; |
| 123 | +} |
| 124 | + |
72 | 125 | function useAutoScroll<T extends HTMLElement>(deps: DependencyList) { |
73 | 126 | const ref = useRef<T | null>(null); |
74 | 127 |
|
|
0 commit comments