diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 854518e9b4..bb68ff5bd1 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -2942,6 +2942,7 @@ class ApiMethods { limit?: number; offset?: number; q?: string; + archived?: boolean; }): Promise => { const response = await this.axios.get( getURLWithSearchParams("/api/experimental/chats", req), diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index ce05e263a0..e13c886396 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -49,7 +49,7 @@ export const readInfiniteChatsCache = ( const DEFAULT_CHAT_PAGE_LIMIT = 50; -export const infiniteChats = (opts?: { q?: string }) => { +export const infiniteChats = (opts?: { q?: string; archived?: boolean }) => { const limit = DEFAULT_CHAT_PAGE_LIMIT; return { @@ -69,6 +69,7 @@ export const infiniteChats = (opts?: { q?: string }) => { limit, offset: pageParam <= 0 ? 0 : (pageParam - 1) * limit, q: opts?.q, + archived: opts?.archived, }); }, refetchOnWindowFocus: true as const, diff --git a/site/src/pages/AgentsPage/AgentsPage.tsx b/site/src/pages/AgentsPage/AgentsPage.tsx index 8d95259068..5a1424a899 100644 --- a/site/src/pages/AgentsPage/AgentsPage.tsx +++ b/site/src/pages/AgentsPage/AgentsPage.tsx @@ -75,6 +75,10 @@ const AgentsPage: FC = () => { permissions.editDeploymentConfig || user.roles.some((role) => role.name === "owner" || role.name === "admin"); + const [archivedFilter, setArchivedFilter] = useState<"active" | "archived">( + "active", + ); + // The global CSS sets scrollbar-gutter: stable on to prevent // layout shift on pages that toggle scrollbars. The agents page // uses its own internal scroll containers so the reserved gutter @@ -118,7 +122,9 @@ const AgentsPage: FC = () => { }; }, []); - const chatsQuery = useInfiniteQuery(infiniteChats()); + const chatsQuery = useInfiniteQuery( + infiniteChats({ archived: archivedFilter === "archived" }), + ); const chatModelsQuery = useQuery(chatModels()); const chatModelConfigsQuery = useQuery(chatModelConfigs()); const createMutation = useMutation(createChat(queryClient)); @@ -484,6 +490,9 @@ const AgentsPage: FC = () => { isAgentsAdmin={isAgentsAdmin} hasNextPage={chatsQuery.hasNextPage} onLoadMore={() => void chatsQuery.fetchNextPage()} + isFetchingNextPage={chatsQuery.isFetchingNextPage} + archivedFilter={archivedFilter} + onArchivedFilterChange={setArchivedFilter} /> ); }; diff --git a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx index d4744af583..03fab07500 100644 --- a/site/src/pages/AgentsPage/AgentsPageView.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsPageView.stories.tsx @@ -96,6 +96,9 @@ const meta: Meta = { onToggleSidebarCollapsed: fn(), }, isAgentsAdmin: false, + archivedFilter: "active" as const, + onArchivedFilterChange: fn(), + isFetchingNextPage: false, onCreateChat: fn(), createError: undefined, modelCatalog: undefined, diff --git a/site/src/pages/AgentsPage/AgentsPageView.tsx b/site/src/pages/AgentsPage/AgentsPageView.tsx index 7772212ac7..ddd75d68f7 100644 --- a/site/src/pages/AgentsPage/AgentsPageView.tsx +++ b/site/src/pages/AgentsPage/AgentsPageView.tsx @@ -56,6 +56,9 @@ interface AgentsPageViewProps { modelCatalogError: unknown; hasNextPage: boolean | undefined; onLoadMore: () => void; + isFetchingNextPage: boolean; + archivedFilter: "active" | "archived"; + onArchivedFilterChange: (filter: "active" | "archived") => void; } export const AgentsPageView: FC = ({ @@ -84,6 +87,9 @@ export const AgentsPageView: FC = ({ modelCatalogError, hasNextPage, onLoadMore, + isFetchingNextPage, + archivedFilter, + onArchivedFilterChange, }) => { const { chatErrorReasons, @@ -123,6 +129,9 @@ export const AgentsPageView: FC = ({ onRetryLoad={onRetryChatsLoad} hasNextPage={hasNextPage} onLoadMore={onLoadMore} + isFetchingNextPage={isFetchingNextPage} + archivedFilter={archivedFilter} + onArchivedFilterChange={onArchivedFilterChange} onCollapse={onCollapseSidebar} onOpenSettings={() => setConfigureAgentsDialogOpen(true)} /> diff --git a/site/src/pages/AgentsPage/AgentsSidebar.stories.tsx b/site/src/pages/AgentsPage/AgentsSidebar.stories.tsx index 775d52a840..908ee43304 100644 --- a/site/src/pages/AgentsPage/AgentsSidebar.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsSidebar.stories.tsx @@ -68,6 +68,8 @@ const meta: Meta = { onArchiveAndDeleteWorkspace: fn(), onNewAgent: fn(), isCreating: false, + archivedFilter: "active" as const, + onArchivedFilterChange: fn(), }, parameters: { layout: "fullscreen", @@ -309,7 +311,7 @@ export const ActiveChatAncestryExpanded: Story = { const todayTimestamp = new Date().toISOString(); -export const ArchivedAgentsCollapsed: Story = { +export const ActiveFilterShowsActiveAgents: Story = { args: { chats: [ buildChat({ @@ -322,17 +324,8 @@ export const ArchivedAgentsCollapsed: Story = { title: "Active agent two", updated_at: todayTimestamp, }), - buildChat({ - id: "archived-1", - title: "Archived agent one", - archived: true, - }), - buildChat({ - id: "archived-2", - title: "Archived agent two", - archived: true, - }), ], + archivedFilter: "active", }, parameters: { reactRouter: reactRouterParameters({ @@ -345,37 +338,28 @@ export const ArchivedAgentsCollapsed: Story = { await waitFor(() => { expect(canvas.getByText("Active agent one")).toBeInTheDocument(); expect(canvas.getByText("Active agent two")).toBeInTheDocument(); - expect(canvas.getByText("Archived (2)")).toBeInTheDocument(); }); - expect(canvas.queryByText("Archived agent one")).not.toBeInTheDocument(); - expect(canvas.queryByText("Archived agent two")).not.toBeInTheDocument(); + expect(canvas.getByLabelText("Filter agents")).toBeInTheDocument(); }, }; -export const ArchivedAgentsExpanded: Story = { +export const ArchivedFilterShowsArchivedAgents: Story = { args: { chats: [ - buildChat({ - id: "active-1", - title: "Active agent one", - updated_at: todayTimestamp, - }), - buildChat({ - id: "active-2", - title: "Active agent two", - updated_at: todayTimestamp, - }), buildChat({ id: "archived-1", title: "Archived agent one", archived: true, + updated_at: todayTimestamp, }), buildChat({ id: "archived-2", title: "Archived agent two", archived: true, + updated_at: todayTimestamp, }), ], + archivedFilter: "archived", }, parameters: { reactRouter: reactRouterParameters({ @@ -385,14 +369,11 @@ export const ArchivedAgentsExpanded: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await waitFor(() => { - expect(canvas.getByText("Archived (2)")).toBeInTheDocument(); - }); - await userEvent.click(canvas.getByText("Archived (2)")); await waitFor(() => { expect(canvas.getByText("Archived agent one")).toBeInTheDocument(); expect(canvas.getByText("Archived agent two")).toBeInTheDocument(); }); + expect(canvas.getByLabelText("Filter agents")).toBeInTheDocument(); }, }; @@ -720,8 +701,10 @@ export const ArchivedAgentUnarchiveOption: Story = { id: "archived-unarchive", title: "Archived agent with unarchive", archived: true, + updated_at: todayTimestamp, }), ], + archivedFilter: "archived", }, parameters: { reactRouter: reactRouterParameters({ @@ -731,11 +714,6 @@ export const ArchivedAgentUnarchiveOption: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - // Expand archived section - await waitFor(() => { - expect(canvas.getByText("Archived (1)")).toBeInTheDocument(); - }); - await userEvent.click(canvas.getByText("Archived (1)")); await waitFor(() => { expect( canvas.getByText("Archived agent with unarchive"), diff --git a/site/src/pages/AgentsPage/AgentsSidebar.tsx b/site/src/pages/AgentsPage/AgentsSidebar.tsx index d87e30b501..a028396590 100644 --- a/site/src/pages/AgentsPage/AgentsSidebar.tsx +++ b/site/src/pages/AgentsPage/AgentsSidebar.tsx @@ -8,11 +8,6 @@ import { ErrorAlert } from "components/Alert/ErrorAlert"; import { Avatar } from "components/Avatar/Avatar"; import type { ModelSelectorOption } from "components/ai-elements"; import { Button } from "components/Button/Button"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "components/Collapsible/Collapsible"; import { DropdownMenu, DropdownMenuContent, @@ -33,6 +28,7 @@ import { ChevronDownIcon, ChevronRightIcon, EllipsisIcon, + FilterIcon, GitMergeIcon, GitPullRequestArrowIcon, GitPullRequestClosedIcon, @@ -54,6 +50,7 @@ import { useContext, useEffect, useMemo, + useRef, useState, } from "react"; import { NavLink, useParams } from "react-router"; @@ -79,6 +76,9 @@ interface AgentsSidebarProps { onRetryLoad?: () => void; hasNextPage?: boolean; onLoadMore?: () => void; + isFetchingNextPage?: boolean; + archivedFilter: "active" | "archived"; + onArchivedFilterChange?: (filter: "active" | "archived") => void; onCollapse?: () => void; onOpenSettings?: () => void; } @@ -583,6 +583,9 @@ export const AgentsSidebar: FC = (props) => { onRetryLoad, hasNextPage, onLoadMore, + isFetchingNextPage, + archivedFilter, + onArchivedFilterChange, onCollapse, onOpenSettings, } = props; @@ -595,7 +598,6 @@ export const AgentsSidebar: FC = (props) => { const { appearance, buildInfo } = useDashboard(); const normalizedSearch = ""; const [expandedById, setExpandedById] = useState>({}); - const [isArchivedExpanded, setIsArchivedExpanded] = useState(false); const chatTree = useMemo(() => buildChatTree(chats), [chats]); const chatById = useMemo(() => { @@ -614,24 +616,6 @@ export const AgentsSidebar: FC = (props) => { () => chatTree.rootIds.filter((chatID) => visibleChatIDs.has(chatID)), [chatTree.rootIds, visibleChatIDs], ); - const activeRootIDs = useMemo( - () => - visibleRootIDs.filter((id) => { - const chat = chatById.get(id); - return chat && !chat.archived; - }), - [visibleRootIDs, chatById], - ); - const archivedRootIDs = useMemo( - () => - visibleRootIDs.filter((id) => { - const chat = chatById.get(id); - return chat?.archived; - }), - [visibleRootIDs, chatById], - ); - const effectiveArchivedExpanded = - normalizedSearch && archivedRootIDs.length > 0 ? true : isArchivedExpanded; // Auto-expand ancestors of the active chat so it's always visible. useEffect(() => { @@ -706,18 +690,53 @@ export const AgentsSidebar: FC = (props) => { )} - {onCollapse && ( - - )} - +
+ + + + + + onArchivedFilterChange?.("active")} + > + Active + {archivedFilter === "active" && ( + + )} + + onArchivedFilterChange?.("archived")} + > + Archived + {archivedFilter === "archived" && ( + + )} + + + + {onCollapse && ( + + )} +
+ {" "} - + {(hasNextPage || isFetchingNextPage) && ( + )} )} @@ -864,6 +848,7 @@ export const AgentsSidebar: FC = (props) => { type="button" className="flex min-w-0 flex-1 items-center gap-2 bg-transparent border-0 cursor-pointer px-3 py-3 text-left hover:bg-surface-tertiary/50 transition-colors" > + {" "} = (props) => { ); }; + +const LoadMoreSentinel: FC<{ + onLoadMore?: () => void; + isFetchingNextPage?: boolean; +}> = ({ onLoadMore, isFetchingNextPage }) => { + const sentinelRef = useRef(null); + + useEffect(() => { + const el = sentinelRef.current; + if (!el || !onLoadMore) return; + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting) { + onLoadMore(); + } + }, + { threshold: 0 }, + ); + observer.observe(el); + return () => observer.disconnect(); + }, [onLoadMore]); + + return ( +
+ {isFetchingNextPage && ( + + )} +
+ ); +};