feat(agents): add unarchive agent support (#22579)

This commit is contained in:
Danielle Maywood
2026-03-04 14:08:12 +00:00
committed by GitHub
parent 8c09df52f9
commit 90f686d684
10 changed files with 270 additions and 32 deletions
+89
View File
@@ -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()
+4
View File
@@ -2944,6 +2944,10 @@ class ApiMethods {
await this.axios.post(`/api/experimental/chats/${chatId}/archive`);
};
unarchiveChat = async (chatId: string): Promise<void> => {
await this.axios.post(`/api/experimental/chats/${chatId}/unarchive`);
};
createChatMessage = async (
chatId: string,
req: TypesGen.CreateChatMessageRequest,
+7
View File
@@ -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,
@@ -44,6 +44,7 @@ const AgentDetailLayout: FC = () => {
clearChatErrorReason: () => {},
requestArchiveAgent: () => {},
requestArchiveAndDeleteWorkspace: () => {},
requestUnarchiveAgent: () => {},
isSidebarCollapsed: false,
onToggleSidebarCollapsed: () => {},
} satisfies AgentsOutletContext
+14
View File
@@ -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<typeof useChatStore>["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 (
<div className="relative flex h-full min-h-0 min-w-0 flex-1 flex-col">
@@ -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}
@@ -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<typeof AgentDetailTopBar>;
@@ -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();
},
};
@@ -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<AgentDetailTopBarProps> = ({
diff,
workspace,
onArchiveAgent,
onUnarchiveAgent,
onArchiveAndDeleteWorkspace,
hasWorkspace,
isArchived,
@@ -235,25 +238,32 @@ export const AgentDetailTopBar: FC<AgentDetailTopBarProps> = ({
View Workspace
</DropdownMenuItem>
<DropdownMenuSeparator />
{!isArchived && (
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onSelect={onArchiveAgent}
>
<ArchiveIcon className="h-3.5 w-3.5" />
Archive Agent
{isArchived ? (
<DropdownMenuItem onSelect={onUnarchiveAgent}>
<ArchiveRestoreIcon className="h-3.5 w-3.5" />
Unarchive Agent
</DropdownMenuItem>
) : (
<>
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onSelect={onArchiveAgent}
>
<ArchiveIcon className="h-3.5 w-3.5" />
Archive Agent
</DropdownMenuItem>
{hasWorkspace && (
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onSelect={onArchiveAndDeleteWorkspace}
>
<Trash2Icon className="h-3.5 w-3.5" />
Archive & Delete Workspace
</DropdownMenuItem>
)}
</>
)}
{!isArchived && hasWorkspace && (
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onSelect={onArchiveAndDeleteWorkspace}
>
<Trash2Icon className="h-3.5 w-3.5" />
Archive & Delete Workspace
</DropdownMenuItem>
)}
</DropdownMenuContent>{" "}
</DropdownMenuContent>
</DropdownMenu>
<WebPushButton />
</div>
+21
View File
@@ -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}
@@ -61,6 +61,7 @@ const meta: Meta<typeof AgentsSidebar> = {
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();
},
};
+35 -15
View File
@@ -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<ChatTreeNodeProps>(({ chat, isChildNode }) => {
archivingChatId,
toggleExpanded,
onArchiveAgent,
onUnarchiveAgent,
onArchiveAndDeleteWorkspace,
} = useChatTree();
const chatID = chat.id;
@@ -470,25 +474,38 @@ const ChatTreeNode = memo<ChatTreeNodeProps>(({ chat, isChildNode }) => {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
disabled={isArchiving}
onSelect={() => onArchiveAgent(chat.id)}
>
<ArchiveIcon className="h-3.5 w-3.5" />
Archive agent
</DropdownMenuItem>
{workspaceId && (
{chat.archived ? (
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
disabled={isArchiving}
onSelect={() =>
onArchiveAndDeleteWorkspace(chat.id, workspaceId)
}
onSelect={() => onUnarchiveAgent(chat.id)}
>
<Trash2Icon className="h-3.5 w-3.5" />
Archive & delete workspace
<ArchiveRestoreIcon className="h-3.5 w-3.5" />
Unarchive agent
</DropdownMenuItem>
) : (
<>
{" "}
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
disabled={isArchiving}
onSelect={() => onArchiveAgent(chat.id)}
>
<ArchiveIcon className="h-3.5 w-3.5" />
Archive agent
</DropdownMenuItem>
{workspaceId && (
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
disabled={isArchiving}
onSelect={() =>
onArchiveAndDeleteWorkspace(chat.id, workspaceId)
}
>
<Trash2Icon className="h-3.5 w-3.5" />
Archive & delete workspace
</DropdownMenuItem>
)}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
@@ -521,6 +538,7 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
modelConfigs,
logoUrl,
onArchiveAgent,
onUnarchiveAgent,
onArchiveAndDeleteWorkspace,
onNewAgent,
isCreating,
@@ -618,6 +636,7 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
archivingChatId,
toggleExpanded,
onArchiveAgent,
onUnarchiveAgent,
onArchiveAndDeleteWorkspace,
}),
[
@@ -632,6 +651,7 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
archivingChatId,
toggleExpanded,
onArchiveAgent,
onUnarchiveAgent,
onArchiveAndDeleteWorkspace,
],
);