From 90f686d6841bd3483f9787513fcb498c7bdeb22e Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 4 Mar 2026 14:08:12 +0000 Subject: [PATCH] feat(agents): add unarchive agent support (#22579) --- coderd/chats_test.go | 89 +++++++++++++++++++ site/src/api/api.ts | 4 + site/src/api/queries/chats.ts | 7 ++ .../pages/AgentsPage/AgentDetail.stories.tsx | 1 + site/src/pages/AgentsPage/AgentDetail.tsx | 14 +++ .../AgentsPage/AgentDetail/TopBar.stories.tsx | 25 ++++++ .../pages/AgentsPage/AgentDetail/TopBar.tsx | 44 +++++---- site/src/pages/AgentsPage/AgentsPage.tsx | 21 +++++ .../AgentsPage/AgentsSidebar.stories.tsx | 47 ++++++++++ site/src/pages/AgentsPage/AgentsSidebar.tsx | 50 +++++++---- 10 files changed, 270 insertions(+), 32 deletions(-) diff --git a/coderd/chats_test.go b/coderd/chats_test.go index 8498ad2260..425f2a6297 100644 --- a/coderd/chats_test.go +++ b/coderd/chats_test.go @@ -1292,6 +1292,95 @@ func TestArchiveChat(t *testing.T) { }) } +func TestUnarchiveChat(t *testing.T) { + t.Parallel() + + t.Run("Success", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client) + _ = createChatModelConfig(t, client) + + chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ + Content: []codersdk.ChatInputPart{ + { + Type: codersdk.ChatInputPartTypeText, + Text: "archive then unarchive me", + }, + }, + }) + require.NoError(t, err) + + // Archive the chat first. + err = client.ArchiveChat(ctx, chat.ID) + require.NoError(t, err) + + // Verify it's archived. + archivedChats, err := client.ListChats(ctx, &codersdk.ListChatsOptions{ + Archived: ptr.Ref(true), + }) + require.NoError(t, err) + require.Len(t, archivedChats, 1) + require.True(t, archivedChats[0].Archived) + + // Unarchive the chat. + err = client.UnarchiveChat(ctx, chat.ID) + require.NoError(t, err) + + // Verify it's no longer archived. + activeChats, err := client.ListChats(ctx, &codersdk.ListChatsOptions{ + Archived: ptr.Ref(false), + }) + require.NoError(t, err) + require.Len(t, activeChats, 1) + require.Equal(t, chat.ID, activeChats[0].ID) + require.False(t, activeChats[0].Archived) + + // No archived chats remain. + archivedChats, err = client.ListChats(ctx, &codersdk.ListChatsOptions{ + Archived: ptr.Ref(true), + }) + require.NoError(t, err) + require.Empty(t, archivedChats) + }) + + t.Run("NotArchived", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client) + _ = createChatModelConfig(t, client) + + chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ + Content: []codersdk.ChatInputPart{ + { + Type: codersdk.ChatInputPartTypeText, + Text: "not archived", + }, + }, + }) + require.NoError(t, err) + + // Trying to unarchive a non-archived chat should fail. + err = client.UnarchiveChat(ctx, chat.ID) + requireSDKError(t, err, http.StatusBadRequest) + }) + + t.Run("NotFound", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client) + + err := client.UnarchiveChat(ctx, uuid.New()) + requireSDKError(t, err, http.StatusNotFound) + }) +} + func TestPostChatMessages(t *testing.T) { t.Parallel() diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 99715bbc96..e1db002c32 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -2944,6 +2944,10 @@ class ApiMethods { await this.axios.post(`/api/experimental/chats/${chatId}/archive`); }; + unarchiveChat = async (chatId: string): Promise => { + await this.axios.post(`/api/experimental/chats/${chatId}/unarchive`); + }; + createChatMessage = async ( chatId: string, req: TypesGen.CreateChatMessageRequest, diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index a4de31a6fe..56a65fdd3f 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -30,6 +30,13 @@ export const archiveChat = (queryClient: QueryClient) => ({ }, }); +export const unarchiveChat = (queryClient: QueryClient) => ({ + mutationFn: (chatId: string) => API.unarchiveChat(chatId), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: chatsKey }); + }, +}); + export const createChatMessage = ( queryClient: QueryClient, chatId: string, diff --git a/site/src/pages/AgentsPage/AgentDetail.stories.tsx b/site/src/pages/AgentsPage/AgentDetail.stories.tsx index 4d78869e7d..fbf58498b3 100644 --- a/site/src/pages/AgentsPage/AgentDetail.stories.tsx +++ b/site/src/pages/AgentsPage/AgentDetail.stories.tsx @@ -44,6 +44,7 @@ const AgentDetailLayout: FC = () => { clearChatErrorReason: () => {}, requestArchiveAgent: () => {}, requestArchiveAndDeleteWorkspace: () => {}, + requestUnarchiveAgent: () => {}, isSidebarCollapsed: false, onToggleSidebarCollapsed: () => {}, } satisfies AgentsOutletContext diff --git a/site/src/pages/AgentsPage/AgentDetail.tsx b/site/src/pages/AgentsPage/AgentDetail.tsx index 0719b717c8..e2e5f0f7e6 100644 --- a/site/src/pages/AgentsPage/AgentDetail.tsx +++ b/site/src/pages/AgentsPage/AgentDetail.tsx @@ -81,6 +81,8 @@ const noopRequestArchiveAgent: AgentsOutletContext["requestArchiveAgent"] = () => {}; const noopRequestArchiveAndDeleteWorkspace: AgentsOutletContext["requestArchiveAndDeleteWorkspace"] = () => {}; +const noopRequestUnarchiveAgent: AgentsOutletContext["requestUnarchiveAgent"] = + () => {}; const lastModelConfigIDStorageKey = "agents.last-model-config-id"; type ChatStoreHandle = ReturnType["store"]; @@ -407,6 +409,8 @@ const AgentDetail: FC = () => { const requestArchiveAndDeleteWorkspace = outletContext?.requestArchiveAndDeleteWorkspace ?? noopRequestArchiveAndDeleteWorkspace; + const requestUnarchiveAgent = + outletContext?.requestUnarchiveAgent ?? noopRequestUnarchiveAgent; const isSidebarCollapsed = outletContext?.isSidebarCollapsed ?? false; const onToggleSidebarCollapsed = outletContext?.onToggleSidebarCollapsed ?? (() => {}); @@ -766,6 +770,13 @@ const AgentDetail: FC = () => { requestArchiveAndDeleteWorkspace(agentId, workspaceId); }; + const handleUnarchiveAgentAction = () => { + if (!agentId || !isArchived) { + return; + } + requestUnarchiveAgent(agentId); + }; + if (chatQuery.isLoading) { return (
@@ -786,6 +797,7 @@ const AgentDetail: FC = () => { }} onOpenParentChat={() => {}} onArchiveAgent={() => {}} + onUnarchiveAgent={() => {}} onArchiveAndDeleteWorkspace={() => {}} hasWorkspace={false} isSidebarCollapsed={isSidebarCollapsed} @@ -860,6 +872,7 @@ const AgentDetail: FC = () => { }} onOpenParentChat={() => {}} onArchiveAgent={() => {}} + onUnarchiveAgent={() => {}} onArchiveAndDeleteWorkspace={() => {}} hasWorkspace={false} isSidebarCollapsed={isSidebarCollapsed} @@ -900,6 +913,7 @@ const AgentDetail: FC = () => { sshCommand, }} onArchiveAgent={handleArchiveAgentAction} + onUnarchiveAgent={handleUnarchiveAgentAction} onArchiveAndDeleteWorkspace={handleArchiveAndDeleteWorkspaceAction} hasWorkspace={Boolean(workspaceId)} isArchived={isArchived} diff --git a/site/src/pages/AgentsPage/AgentDetail/TopBar.stories.tsx b/site/src/pages/AgentsPage/AgentDetail/TopBar.stories.tsx index fd787b49d1..da03f2bbe7 100644 --- a/site/src/pages/AgentsPage/AgentDetail/TopBar.stories.tsx +++ b/site/src/pages/AgentsPage/AgentDetail/TopBar.stories.tsx @@ -2,6 +2,7 @@ import { MockUserOwner } from "testHelpers/entities"; import { withAuthProvider, withDashboardProvider } from "testHelpers/storybook"; import type { Meta, StoryObj } from "@storybook/react-vite"; import type { ChatDiffStatusResponse } from "api/api"; +import { expect, userEvent, waitFor, within } from "storybook/test"; import { AgentDetailTopBar } from "./TopBar"; const mockDiffStatus: ChatDiffStatusResponse = { @@ -31,6 +32,7 @@ const defaultProps = { }, onArchiveAgent: () => {}, onArchiveAndDeleteWorkspace: () => {}, + onUnarchiveAgent: () => {}, isSidebarCollapsed: false, onToggleSidebarCollapsed: () => {}, } satisfies React.ComponentProps; @@ -105,3 +107,26 @@ export const NoTitle: Story = { chatTitle: undefined, }, }; + +export const ArchivedWithUnarchive: Story = { + args: { + isArchived: true, + onUnarchiveAgent: () => {}, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // Open the actions dropdown + const trigger = canvas.getByLabelText("Open agent actions"); + await userEvent.click(trigger); + // Verify "Unarchive Agent" is shown instead of "Archive Agent" + await waitFor(() => { + const body = within(document.body); + expect(body.getByText("Unarchive Agent")).toBeInTheDocument(); + }); + const body = within(document.body); + expect(body.queryByText("Archive Agent")).not.toBeInTheDocument(); + expect( + body.queryByText("Archive & Delete Workspace"), + ).not.toBeInTheDocument(); + }, +}; diff --git a/site/src/pages/AgentsPage/AgentDetail/TopBar.tsx b/site/src/pages/AgentsPage/AgentDetail/TopBar.tsx index f460291083..f3afc97b21 100644 --- a/site/src/pages/AgentsPage/AgentDetail/TopBar.tsx +++ b/site/src/pages/AgentsPage/AgentDetail/TopBar.tsx @@ -11,6 +11,7 @@ import { import { useAuthenticated } from "hooks"; import { ArchiveIcon, + ArchiveRestoreIcon, ArrowLeftIcon, ChevronRightIcon, CopyIcon, @@ -88,6 +89,7 @@ type AgentDetailTopBarProps = { diff: DiffPanelState; workspace: WorkspaceActions; onArchiveAgent: () => void; + onUnarchiveAgent: () => void; onArchiveAndDeleteWorkspace: () => void; hasWorkspace?: boolean; isArchived?: boolean; @@ -102,6 +104,7 @@ export const AgentDetailTopBar: FC = ({ diff, workspace, onArchiveAgent, + onUnarchiveAgent, onArchiveAndDeleteWorkspace, hasWorkspace, isArchived, @@ -235,25 +238,32 @@ export const AgentDetailTopBar: FC = ({ View Workspace - {!isArchived && ( - - - Archive Agent + {isArchived ? ( + + + Unarchive Agent + ) : ( + <> + + + Archive Agent + + {hasWorkspace && ( + + + Archive & Delete Workspace + + )} + )} - {!isArchived && hasWorkspace && ( - - - Archive & Delete Workspace - - )} - {" "} +
diff --git a/site/src/pages/AgentsPage/AgentsPage.tsx b/site/src/pages/AgentsPage/AgentsPage.tsx index ba62bd4f9f..70b6b942ec 100644 --- a/site/src/pages/AgentsPage/AgentsPage.tsx +++ b/site/src/pages/AgentsPage/AgentsPage.tsx @@ -10,6 +10,7 @@ import { chats, chatsKey, createChat, + unarchiveChat, } from "api/queries/chats"; import { workspaces } from "api/queries/workspaces"; import type * as TypesGen from "api/typesGenerated"; @@ -88,6 +89,7 @@ export interface AgentsOutletContext { setChatErrorReason: (chatId: string, reason: string) => void; clearChatErrorReason: (chatId: string) => void; requestArchiveAgent: (chatId: string) => void; + requestUnarchiveAgent: (chatId: string) => void; requestArchiveAndDeleteWorkspace: ( chatId: string, workspaceId: string, @@ -188,6 +190,16 @@ const AgentsPage: FC = () => { toast.error(getErrorMessage(error, "Failed to archive agent.")); }, }); + const unarchiveAgentMutation = useMutation({ + ...unarchiveChat(queryClient), + onSuccess: async (_data, chatId) => { + await queryClient.invalidateQueries({ queryKey: chatKey(chatId) }); + toast.success("Agent unarchived."); + }, + onError: (error) => { + toast.error(getErrorMessage(error, "Failed to unarchive agent.")); + }, + }); const [isConfigureAgentsDialogOpen, setConfigureAgentsDialogOpen] = useState(false); @@ -276,6 +288,12 @@ const AgentsPage: FC = () => { }, [isArchiving, archiveAndDeleteMutation], ); + const requestUnarchiveAgent = useCallback( + (chatId: string) => { + unarchiveAgentMutation.mutate(chatId); + }, + [unarchiveAgentMutation], + ); const handleToggleSidebarCollapsed = useCallback( () => setIsSidebarCollapsed((prev) => !prev), [], @@ -286,6 +304,7 @@ const AgentsPage: FC = () => { setChatErrorReason, clearChatErrorReason, requestArchiveAgent, + requestUnarchiveAgent, requestArchiveAndDeleteWorkspace, isSidebarCollapsed, onToggleSidebarCollapsed: handleToggleSidebarCollapsed, @@ -295,6 +314,7 @@ const AgentsPage: FC = () => { setChatErrorReason, clearChatErrorReason, requestArchiveAgent, + requestUnarchiveAgent, requestArchiveAndDeleteWorkspace, isSidebarCollapsed, handleToggleSidebarCollapsed, @@ -452,6 +472,7 @@ const AgentsPage: FC = () => { modelConfigs={chatModelConfigsQuery.data ?? []} logoUrl={appearance.logo_url} onArchiveAgent={requestArchiveAgent} + onUnarchiveAgent={requestUnarchiveAgent} onArchiveAndDeleteWorkspace={requestArchiveAndDeleteWorkspace} onNewAgent={handleNewAgent} isCreating={createMutation.isPending} diff --git a/site/src/pages/AgentsPage/AgentsSidebar.stories.tsx b/site/src/pages/AgentsPage/AgentsSidebar.stories.tsx index 9ee955c373..b0687332c0 100644 --- a/site/src/pages/AgentsPage/AgentsSidebar.stories.tsx +++ b/site/src/pages/AgentsPage/AgentsSidebar.stories.tsx @@ -61,6 +61,7 @@ const meta: Meta = { modelOptions: defaultModelOptions, modelConfigs: defaultModelConfigs, onArchiveAgent: fn(), + onUnarchiveAgent: fn(), onArchiveAndDeleteWorkspace: fn(), onNewAgent: fn(), isCreating: false, @@ -459,3 +460,49 @@ export const DefaultShowsTimestampHidesMenu: Story = { }), }, }; + +export const ArchivedAgentUnarchiveOption: Story = { + args: { + chats: [ + buildChat({ + id: "archived-unarchive", + title: "Archived agent with unarchive", + archived: true, + }), + ], + }, + parameters: { + reactRouter: reactRouterParameters({ + location: { path: "/agents" }, + routing: agentsRouting, + }), + }, + 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"), + ).toBeInTheDocument(); + }); + // Open the dropdown menu for the archived agent + const trigger = canvas.getByLabelText( + "Open actions for Archived agent with unarchive", + ); + await userEvent.click(trigger); + // Verify "Unarchive agent" is shown instead of "Archive agent" + await waitFor(() => { + const body = within(document.body); + expect(body.getByText("Unarchive agent")).toBeInTheDocument(); + }); + const body = within(document.body); + expect(body.queryByText("Archive agent")).not.toBeInTheDocument(); + expect( + body.queryByText("Archive & delete workspace"), + ).not.toBeInTheDocument(); + }, +}; diff --git a/site/src/pages/AgentsPage/AgentsSidebar.tsx b/site/src/pages/AgentsPage/AgentsSidebar.tsx index e7994106fa..354e99c3ac 100644 --- a/site/src/pages/AgentsPage/AgentsSidebar.tsx +++ b/site/src/pages/AgentsPage/AgentsSidebar.tsx @@ -25,6 +25,7 @@ import { Skeleton } from "components/Skeleton/Skeleton"; import { AlertTriangleIcon, ArchiveIcon, + ArchiveRestoreIcon, CheckIcon, ChevronDownIcon, ChevronRightIcon, @@ -56,6 +57,7 @@ interface AgentsSidebarProps { modelConfigs: readonly ChatModelConfig[]; logoUrl?: string; onArchiveAgent: (chatId: string) => void; + onUnarchiveAgent: (chatId: string) => void; onArchiveAndDeleteWorkspace: (chatId: string, workspaceId: string) => void; onNewAgent: () => void; isCreating: boolean; @@ -270,6 +272,7 @@ interface ChatTreeContextValue { readonly archivingChatId: string | null; readonly toggleExpanded: (chatID: string) => void; readonly onArchiveAgent: (chatId: string) => void; + readonly onUnarchiveAgent: (chatId: string) => void; readonly onArchiveAndDeleteWorkspace: ( chatId: string, workspaceId: string, @@ -305,6 +308,7 @@ const ChatTreeNode = memo(({ chat, isChildNode }) => { archivingChatId, toggleExpanded, onArchiveAgent, + onUnarchiveAgent, onArchiveAndDeleteWorkspace, } = useChatTree(); const chatID = chat.id; @@ -470,25 +474,38 @@ const ChatTreeNode = memo(({ chat, isChildNode }) => { - onArchiveAgent(chat.id)} - > - - Archive agent - - {workspaceId && ( + {chat.archived ? ( - onArchiveAndDeleteWorkspace(chat.id, workspaceId) - } + onSelect={() => onUnarchiveAgent(chat.id)} > - - Archive & delete workspace + + Unarchive agent + ) : ( + <> + {" "} + onArchiveAgent(chat.id)} + > + + Archive agent + + {workspaceId && ( + + onArchiveAndDeleteWorkspace(chat.id, workspaceId) + } + > + + Archive & delete workspace + + )} + )} @@ -521,6 +538,7 @@ export const AgentsSidebar: FC = (props) => { modelConfigs, logoUrl, onArchiveAgent, + onUnarchiveAgent, onArchiveAndDeleteWorkspace, onNewAgent, isCreating, @@ -618,6 +636,7 @@ export const AgentsSidebar: FC = (props) => { archivingChatId, toggleExpanded, onArchiveAgent, + onUnarchiveAgent, onArchiveAndDeleteWorkspace, }), [ @@ -632,6 +651,7 @@ export const AgentsSidebar: FC = (props) => { archivingChatId, toggleExpanded, onArchiveAgent, + onUnarchiveAgent, onArchiveAndDeleteWorkspace, ], );