feat(agents): replace Show More with infinite scroll and add archived filter dropdown (#22960)

## Summary

Replace the janky "Show more" button in the agents sidebar with
IntersectionObserver-based infinite scroll. Add a filter dropdown near
the top of the sidebar to switch between **Active** (default) and
**Archived** views.

The old collapsible "Archived" section at the bottom of the sidebar is
removed in favor of server-side filtering via the query parameter.

## Changes

### API layer
- `api.ts`: Accept `archived` param in `getChats()`
- `chats.ts`: Accept `archived` option in `infiniteChats()`, pass it
through to API

### Agents page
- `AgentsPage.tsx`: Add `archivedFilter` state, pass `archived` to
query, forward `isFetchingNextPage`
- `AgentsPageView.tsx`: Pass new filter and pagination props through to
sidebar

### Sidebar
- `AgentsSidebar.tsx`:
- Add `LoadMoreSentinel` component using `IntersectionObserver` for
auto-loading
  - Add filter dropdown with Active/Archived options (with checkmarks)
  - Remove `Collapsible` archived section and related state
  - All visible chats now come from the server-side filtered query

### Stories
- Updated stories with new required props (`archivedFilter`, etc.)
- Replaced old archived collapsible stories with filter-based
equivalents
This commit is contained in:
Kyle Carberry
2026-03-11 17:52:37 -04:00
committed by GitHub
parent 57dc23f603
commit 4d7eb2ae4b
7 changed files with 138 additions and 121 deletions
+1
View File
@@ -2942,6 +2942,7 @@ class ApiMethods {
limit?: number;
offset?: number;
q?: string;
archived?: boolean;
}): Promise<TypesGen.Chat[]> => {
const response = await this.axios.get<TypesGen.Chat[]>(
getURLWithSearchParams("/api/experimental/chats", req),
+2 -1
View File
@@ -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,
+10 -1
View File
@@ -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 <html> 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}
/>
);
};
@@ -96,6 +96,9 @@ const meta: Meta<typeof AgentsPageView> = {
onToggleSidebarCollapsed: fn(),
},
isAgentsAdmin: false,
archivedFilter: "active" as const,
onArchivedFilterChange: fn(),
isFetchingNextPage: false,
onCreateChat: fn(),
createError: undefined,
modelCatalog: undefined,
@@ -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<AgentsPageViewProps> = ({
@@ -84,6 +87,9 @@ export const AgentsPageView: FC<AgentsPageViewProps> = ({
modelCatalogError,
hasNextPage,
onLoadMore,
isFetchingNextPage,
archivedFilter,
onArchivedFilterChange,
}) => {
const {
chatErrorReasons,
@@ -123,6 +129,9 @@ export const AgentsPageView: FC<AgentsPageViewProps> = ({
onRetryLoad={onRetryChatsLoad}
hasNextPage={hasNextPage}
onLoadMore={onLoadMore}
isFetchingNextPage={isFetchingNextPage}
archivedFilter={archivedFilter}
onArchivedFilterChange={onArchivedFilterChange}
onCollapse={onCollapseSidebar}
onOpenSettings={() => setConfigureAgentsDialogOpen(true)}
/>
@@ -68,6 +68,8 @@ const meta: Meta<typeof AgentsSidebar> = {
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"),
+101 -85
View File
@@ -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<AgentsSidebarProps> = (props) => {
onRetryLoad,
hasNextPage,
onLoadMore,
isFetchingNextPage,
archivedFilter,
onArchivedFilterChange,
onCollapse,
onOpenSettings,
} = props;
@@ -595,7 +598,6 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
const { appearance, buildInfo } = useDashboard();
const normalizedSearch = "";
const [expandedById, setExpandedById] = useState<Record<string, boolean>>({});
const [isArchivedExpanded, setIsArchivedExpanded] = useState(false);
const chatTree = useMemo(() => buildChatTree(chats), [chats]);
const chatById = useMemo(() => {
@@ -614,24 +616,6 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (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<AgentsSidebarProps> = (props) => {
<CoderIcon className="h-6 w-6 fill-content-primary" />
)}
</NavLink>
{onCollapse && (
<Button
variant="subtle"
size="icon"
onClick={onCollapse}
aria-label="Collapse sidebar"
className="h-7 w-7 min-w-0 text-content-secondary hover:text-content-primary"
>
<PanelLeftCloseIcon />
</Button>
)}
</div>
<div className="flex items-center gap-0.5">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="subtle"
size="icon"
aria-label="Filter agents"
className={cn(
"h-7 w-7 min-w-0 text-content-secondary hover:text-content-primary",
archivedFilter === "archived" && "text-content-primary",
)}
>
<FilterIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={() => onArchivedFilterChange?.("active")}
>
Active
{archivedFilter === "active" && (
<CheckIcon className="ml-auto h-3.5 w-3.5" />
)}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => onArchivedFilterChange?.("archived")}
>
Archived
{archivedFilter === "archived" && (
<CheckIcon className="ml-auto h-3.5 w-3.5" />
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{onCollapse && (
<Button
variant="subtle"
size="icon"
onClick={onCollapse}
aria-label="Collapse sidebar"
className="h-7 w-7 min-w-0 text-content-secondary hover:text-content-primary"
>
<PanelLeftCloseIcon />
</Button>
)}
</div>
</div>{" "}
<Button
size="sm"
variant="subtle"
@@ -766,16 +785,20 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
</>
) : (
<ChatTreeContext.Provider value={chatTreeCtx}>
{activeRootIDs.length === 0 && archivedRootIDs.length === 0 ? (
{visibleRootIDs.length === 0 ? (
<div className="rounded-lg border border-dashed border-border-default bg-surface-primary p-4 text-center text-xs text-content-secondary">
{normalizedSearch ? "No matching agents" : "No agents yet"}
{normalizedSearch
? "No matching agents"
: archivedFilter === "archived"
? "No archived agents"
: "No agents yet"}
</div>
) : (
<div className="divide-y divide-border">
{activeRootIDs.length > 0 && (
<div>
{visibleRootIDs.length > 0 && (
<div className="pb-2">
{TIME_GROUPS.map((group) => {
const groupChats = activeRootIDs
const groupChats = visibleRootIDs
.map((id) => chatById.get(id))
.filter(
(chat): chat is Chat =>
@@ -805,52 +828,13 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
})}
</div>
)}
{archivedRootIDs.length > 0 && (
<Collapsible
className="pt-2"
open={effectiveArchivedExpanded}
onOpenChange={setIsArchivedExpanded}
>
<CollapsibleTrigger asChild>
<div className="mb-1 ml-2.5 flex cursor-pointer items-center justify-between text-xs font-medium text-content-secondary">
<span>Archived ({archivedRootIDs.length})</span>
{effectiveArchivedExpanded ? (
<ChevronDownIcon className="h-3 w-3" />
) : (
<ChevronRightIcon className="h-3 w-3" />
)}
</div>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="flex flex-col gap-0.5">
{archivedRootIDs.map((id) => {
const chat = chatById.get(id);
if (!chat) return null;
return (
<ChatTreeNode
key={chat.id}
chat={chat}
isChildNode={false}
/>
);
})}
</div>
</CollapsibleContent>
</Collapsible>
)}
</div>
)}
{hasNextPage && (
<div className="px-2 py-2">
<Button
size="sm"
variant="outline"
className="w-full"
onClick={onLoadMore}
>
Show more
</Button>
</div>
{(hasNextPage || isFetchingNextPage) && (
<LoadMoreSentinel
onLoadMore={onLoadMore}
isFetchingNextPage={isFetchingNextPage}
/>
)}
</ChatTreeContext.Provider>
)}
@@ -864,6 +848,7 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (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"
>
{" "}
<Avatar
fallback={user.username}
src={user.avatar_url}
@@ -903,3 +888,34 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
</div>
);
};
const LoadMoreSentinel: FC<{
onLoadMore?: () => void;
isFetchingNextPage?: boolean;
}> = ({ onLoadMore, isFetchingNextPage }) => {
const sentinelRef = useRef<HTMLDivElement>(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 (
<div ref={sentinelRef} className="flex items-center justify-center py-2">
{isFetchingNextPage && (
<Spinner className="h-4 w-4 text-content-secondary" loading />
)}
</div>
);
};