1- import { useCallback , useMemo , useState } from 'react' ;
1+ import { useCallback , useEffect , useMemo , useRef , useState } from 'react' ;
22
33import Image from 'next/image' ;
44import { useRouter } from 'next/navigation' ;
55
66import { formatDistanceToNow } from 'date-fns' ;
77import { AnimatePresence , motion } from 'framer-motion' ;
88import {
9+ Loader2 ,
910 MessageSquarePlus ,
1011 PanelLeftClose ,
1112 PanelLeftOpen ,
@@ -18,7 +19,7 @@ import { useAgentexClient } from '@/components/providers';
1819import { Button } from '@/components/ui/button' ;
1920import { Separator } from '@/components/ui/separator' ;
2021import { Skeleton } from '@/components/ui/skeleton' ;
21- import { useTasks } from '@/hooks/use-tasks' ;
22+ import { useInfiniteTasks } from '@/hooks/use-tasks' ;
2223import { cn } from '@/lib/utils' ;
2324
2425import type { Task } from 'agentex/resources' ;
@@ -33,32 +34,55 @@ function TaskButton({ task, selectedTaskID, selectTask }: TaskButtonProps) {
3334 const taskName = createTaskName ( task ) ;
3435
3536 return (
36- < Button
37- variant = "ghost"
38- className = { `hover:bg-sidebar-accent hover:text-sidebar-primary-foreground flex h-auto w-full cursor-pointer flex-col items-start justify-start gap-1 px-2 py-2 text-left transition-colors ${
39- selectedTaskID === task . id
40- ? 'bg-sidebar-primary text-sidebar-primary-foreground'
41- : 'text-sidebar-foreground'
42- } `}
43- onClick = { ( ) => selectTask ( task . id ) }
44- onKeyDown = { e => {
45- if ( e . key === 'Enter' || e . key === ' ' ) {
46- selectTask ( task . id ) ;
47- }
37+ < motion . div
38+ className = ""
39+ layout
40+ initial = { { opacity : 0 , x : - 50 } }
41+ animate = { { opacity : 1 , x : 0 } }
42+ exit = { { opacity : 0 , x : - 50 } }
43+ transition = { {
44+ layout : { duration : 0.3 , ease : 'easeInOut' } ,
45+ opacity : {
46+ duration : 0.2 ,
47+ delay : 0.2 ,
48+ } ,
49+ x : {
50+ delay : 0.2 ,
51+ type : 'spring' ,
52+ damping : 30 ,
53+ stiffness : 300 ,
54+ } ,
4855 } }
4956 >
50- < span className = "w-full truncate text-sm" > { taskName } </ span >
51- < span
52- className = { cn (
53- 'text-muted-foreground text-xs' ,
54- task . created_at ? 'block' : 'invisible'
55- ) }
57+ < Button
58+ variant = "ghost"
59+ className = { `hover:bg-sidebar-accent hover:text-sidebar-primary-foreground flex h-auto w-full cursor-pointer flex-col items-start justify-start gap-1 px-2 py-2 text-left transition-colors ${
60+ selectedTaskID === task . id
61+ ? 'bg-sidebar-primary text-sidebar-primary-foreground'
62+ : 'text-sidebar-foreground'
63+ } `}
64+ onClick = { ( ) => selectTask ( task . id ) }
65+ onKeyDown = { e => {
66+ if ( e . key === 'Enter' || e . key === ' ' ) {
67+ selectTask ( task . id ) ;
68+ }
69+ } }
5670 >
57- { task . created_at
58- ? formatDistanceToNow ( new Date ( task . created_at ) , { addSuffix : true } )
59- : 'No date' }
60- </ span >
61- </ Button >
71+ < span className = "w-full truncate text-sm" > { taskName } </ span >
72+ < span
73+ className = { cn (
74+ 'text-muted-foreground text-xs' ,
75+ task . created_at ? 'block' : 'invisible'
76+ ) }
77+ >
78+ { task . created_at
79+ ? formatDistanceToNow ( new Date ( task . created_at ) , {
80+ addSuffix : true ,
81+ } )
82+ : 'No date' }
83+ </ span >
84+ </ Button >
85+ </ motion . div >
6286 ) ;
6387}
6488
@@ -75,15 +99,43 @@ export function TaskSidebar({
7599} : TaskSidebarProps ) {
76100 const { agentexClient } = useAgentexClient ( ) ;
77101
78- const { data : tasks = [ ] , isLoading : isLoadingTasks } = useTasks (
102+ const {
103+ data,
104+ isLoading : isLoadingTasks ,
105+ fetchNextPage,
106+ hasNextPage,
107+ isFetchingNextPage,
108+ } = useInfiniteTasks (
79109 agentexClient ,
80110 selectedAgentName ? { agentName : selectedAgentName } : undefined
81111 ) ;
82112
83113 const [ isCollapsed , setIsCollapsed ] = useState < boolean > ( false ) ;
114+ const scrollContainerRef = useRef < HTMLDivElement > ( null ) ;
115+
116+ // Flatten all pages into a single array of tasks
117+ const tasks = useMemo ( ( ) => {
118+ return data ?. pages . flatMap ( page => page ) ?? [ ] ;
119+ } , [ data ] ) ;
120+
121+ // Scroll detection for infinite loading
122+ useEffect ( ( ) => {
123+ const scrollContainer = scrollContainerRef . current ;
124+ if ( ! scrollContainer ) return ;
84125
85- // Reverse tasks to show newest first
86- const reversedTasks = useMemo ( ( ) => [ ...tasks ] . reverse ( ) , [ tasks ] ) ;
126+ const handleScroll = ( ) => {
127+ const { scrollTop, scrollHeight, clientHeight } = scrollContainer ;
128+ // Trigger fetch when user is within 100px of the bottom
129+ const isNearBottom = scrollHeight - scrollTop - clientHeight < 100 ;
130+
131+ if ( isNearBottom && hasNextPage && ! isFetchingNextPage ) {
132+ fetchNextPage ( ) ;
133+ }
134+ } ;
135+
136+ scrollContainer . addEventListener ( 'scroll' , handleScroll ) ;
137+ return ( ) => scrollContainer . removeEventListener ( 'scroll' , handleScroll ) ;
138+ } , [ hasNextPage , isFetchingNextPage , fetchNextPage ] ) ;
87139
88140 const handleTaskSelect = useCallback (
89141 ( taskID : Task [ 'id' ] | null ) => {
@@ -104,7 +156,7 @@ export function TaskSidebar({
104156 < ResizableSidebar
105157 side = "left"
106158 storageKey = "taskSidebarWidth"
107- className = "px-2 py-4"
159+ className = "py-4"
108160 isCollapsed = { isCollapsed }
109161 renderCollapsed = { ( ) => (
110162 < div className = "flex flex-col items-center gap-4" >
@@ -129,7 +181,10 @@ export function TaskSidebar({
129181 toggleCollapse = { toggleCollapse }
130182 />
131183 < Separator />
132- < div className = "hide-scrollbar flex flex-col gap-1 overflow-y-auto" >
184+ < div
185+ ref = { scrollContainerRef }
186+ className = "flex flex-col gap-1 overflow-y-auto px-2"
187+ >
133188 { isLoadingTasks ? (
134189 < >
135190 { [ ...Array ( 8 ) ] . map ( ( _ , i ) => (
@@ -140,37 +195,24 @@ export function TaskSidebar({
140195 ) ) }
141196 </ >
142197 ) : (
143- < AnimatePresence initial = { false } >
144- { reversedTasks . length > 0 &&
145- reversedTasks . map ( task => (
146- < motion . div
147- key = { task . id }
148- layout
149- initial = { { opacity : 0 , x : - 50 } }
150- animate = { { opacity : 1 , x : 0 } }
151- exit = { { opacity : 0 , x : - 50 } }
152- transition = { {
153- layout : { duration : 0.3 , ease : 'easeInOut' } ,
154- opacity : {
155- duration : 0.2 ,
156- delay : 0.2 ,
157- } ,
158- x : {
159- delay : 0.2 ,
160- type : 'spring' ,
161- damping : 30 ,
162- stiffness : 300 ,
163- } ,
164- } }
165- >
198+ < >
199+ < AnimatePresence initial = { false } >
200+ { tasks . length > 0 &&
201+ tasks . map ( task => (
166202 < TaskButton
203+ key = { task . id }
167204 task = { task }
168205 selectedTaskID = { selectedTaskID }
169206 selectTask = { handleTaskSelect }
170207 />
171- </ motion . div >
172- ) ) }
173- </ AnimatePresence >
208+ ) ) }
209+ </ AnimatePresence >
210+ { isFetchingNextPage && (
211+ < div className = "flex items-center justify-center py-4" >
212+ < Loader2 className = "text-muted-foreground size-5 animate-spin" />
213+ </ div >
214+ ) }
215+ </ >
174216 ) }
175217 </ div >
176218 </ div >
0 commit comments