Skip to content

Commit 5179183

Browse files
authored
Infinite scrolling in task list (#8)
Adds infinite scrolling in the task list via a new useInfiniteTasks query hook. Limit is set to 30 by default. https://github.com/user-attachments/assets/8f30cb08-8bab-4f4f-96b3-a04e8a8dd528 Currently, the tasks are showing in reverse order due to the default sort on the server being asc. We should change this to be desc in the future, but that shouldn't block this PR
1 parent 3ceb5b7 commit 5179183

File tree

4 files changed

+138
-63
lines changed

4 files changed

+138
-63
lines changed

agentex-ui/components/agentex/task-sidebar.tsx

Lines changed: 98 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import { useCallback, useMemo, useState } from 'react';
1+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
22

33
import Image from 'next/image';
44
import { useRouter } from 'next/navigation';
55

66
import { formatDistanceToNow } from 'date-fns';
77
import { AnimatePresence, motion } from 'framer-motion';
88
import {
9+
Loader2,
910
MessageSquarePlus,
1011
PanelLeftClose,
1112
PanelLeftOpen,
@@ -18,7 +19,7 @@ import { useAgentexClient } from '@/components/providers';
1819
import { Button } from '@/components/ui/button';
1920
import { Separator } from '@/components/ui/separator';
2021
import { Skeleton } from '@/components/ui/skeleton';
21-
import { useTasks } from '@/hooks/use-tasks';
22+
import { useInfiniteTasks } from '@/hooks/use-tasks';
2223
import { cn } from '@/lib/utils';
2324

2425
import 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>

agentex-ui/hooks/use-tasks.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { useQuery } from '@tanstack/react-query';
1+
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
22

33
import type AgentexSDK from 'agentex';
4-
import type { Task } from 'agentex/resources';
4+
import type { Task, TaskListParams } from 'agentex/resources';
55

66
/**
77
* Query key factory for tasks
@@ -54,3 +54,36 @@ export function useTask({
5454
staleTime: 30 * 1000,
5555
});
5656
}
57+
58+
/**
59+
* useQuery hook for infinite scrolling tasks
60+
*/
61+
export function useInfiniteTasks(
62+
agentexClient: AgentexSDK,
63+
options?: { agentName?: string; limit?: number }
64+
) {
65+
const { agentName, limit = 30 } = options || {};
66+
67+
return useInfiniteQuery({
68+
queryKey: tasksKeys.byAgentName(agentName),
69+
queryFn: async ({ pageParam = 1 }): Promise<Task[]> => {
70+
const params: TaskListParams | undefined = agentName
71+
? {
72+
agent_name: agentName,
73+
limit,
74+
page_number: pageParam as number,
75+
}
76+
: undefined;
77+
return agentexClient.tasks.list(params);
78+
},
79+
getNextPageParam: (lastPage, allPages) => {
80+
if (lastPage.length < limit) {
81+
return undefined;
82+
}
83+
return allPages.length + 1;
84+
},
85+
initialPageParam: 1,
86+
staleTime: 30 * 1000,
87+
refetchOnWindowFocus: true,
88+
});
89+
}

agentex-ui/package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

agentex-ui/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
"@tailwindcss/postcss": "^4",
3636
"@tanstack/react-query": "^5.90.3",
3737
"@uiw/react-codemirror": "^4.25.2",
38-
"agentex": "^0.1.0-alpha.6",
38+
"agentex": "^0.1.0-alpha.7",
3939
"ai": "^5.0.72",
4040
"class-variance-authority": "^0.7.1",
4141
"clsx": "^2.1.1",

0 commit comments

Comments
 (0)