mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user