feat: virtual desktop settings toggle frontend (#23173)

Add a toggle in agents settings to enable/disable virtual desktop. The
Desktop tab (next to the Git tab) will only be visible if the feature is
enabled.

<img width="879" height="648" alt="Screenshot 2026-03-17 at 18 01 26"
src="https://github.com/user-attachments/assets/09fc3850-c88d-4c5c-b6e4-760590e53b95"
/>
This commit is contained in:
Hugo Dutka
2026-03-18 09:50:14 +01:00
committed by GitHub
parent 2cf47ec384
commit 817fb4e67a
6 changed files with 186 additions and 3 deletions
+15
View File
@@ -3079,6 +3079,21 @@ class ApiMethods {
await this.axios.put("/api/experimental/chats/config/system-prompt", req);
};
getChatDesktopEnabled =
async (): Promise<TypesGen.ChatDesktopEnabledResponse> => {
const response =
await this.axios.get<TypesGen.ChatDesktopEnabledResponse>(
"/api/experimental/chats/config/desktop-enabled",
);
return response.data;
};
updateChatDesktopEnabled = async (
req: TypesGen.UpdateChatDesktopEnabledRequest,
): Promise<void> => {
await this.axios.put("/api/experimental/chats/config/desktop-enabled", req);
};
getUserChatCustomPrompt =
async (): Promise<TypesGen.UserChatCustomPrompt> => {
const response = await this.axios.get<TypesGen.UserChatCustomPrompt>(
+16
View File
@@ -395,6 +395,22 @@ export const updateChatSystemPrompt = (queryClient: QueryClient) => ({
},
});
const chatDesktopEnabledKey = ["chat-desktop-enabled"] as const;
export const chatDesktopEnabled = () => ({
queryKey: chatDesktopEnabledKey,
queryFn: () => API.getChatDesktopEnabled(),
});
export const updateChatDesktopEnabled = (queryClient: QueryClient) => ({
mutationFn: API.updateChatDesktopEnabled,
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: chatDesktopEnabledKey,
});
},
});
const chatUserCustomPromptKey = ["chat-user-custom-prompt"] as const;
export const chatUserCustomPrompt = () => ({
+5 -1
View File
@@ -2,6 +2,7 @@ import { API, watchWorkspace } from "api/api";
import { isApiError } from "api/errors";
import {
chat,
chatDesktopEnabled,
chatMessagesForInfiniteScroll,
chatModelConfigs,
chatModels,
@@ -317,6 +318,7 @@ const AgentDetail: FC = () => {
}, [workspaceId, queryClient]);
const chatModelsQuery = useQuery(chatModels());
const chatModelConfigsQuery = useQuery(chatModelConfigs());
const desktopEnabledQuery = useQuery(chatDesktopEnabled());
const sshConfigQuery = useQuery(deploymentSSHConfig());
const workspace = workspaceQuery.data;
const workspaceAgent = getWorkspaceAgent(workspace, undefined);
@@ -901,7 +903,9 @@ const AgentDetail: FC = () => {
hasMoreMessages={chatMessagesQuery.hasNextPage ?? false}
isFetchingMoreMessages={chatMessagesQuery.isFetchingNextPage}
onFetchMoreMessages={chatMessagesQuery.fetchNextPage}
desktopChatId={agentId}
desktopChatId={
desktopEnabledQuery.data?.enable_desktop ? agentId : undefined
}
/>
);
};
@@ -0,0 +1,70 @@
import { MockUserOwner } from "testHelpers/entities";
import { withAuthProvider, withDashboardProvider } from "testHelpers/storybook";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { API } from "api/api";
import dayjs from "dayjs";
import { expect, spyOn, userEvent, waitFor, within } from "storybook/test";
import { SettingsPageContent } from "./SettingsPageContent";
const meta = {
title: "pages/AgentsPage/SettingsPageContent",
component: SettingsPageContent,
decorators: [withAuthProvider, withDashboardProvider],
args: {
activeSection: "behavior",
canManageChatModelConfigs: false,
canSetSystemPrompt: true,
now: dayjs("2026-03-12T00:00:00Z"),
},
parameters: {
user: MockUserOwner,
layout: "fullscreen",
},
beforeEach: () => {
spyOn(API, "getChatSystemPrompt").mockResolvedValue({
system_prompt: "",
});
spyOn(API, "updateChatSystemPrompt").mockResolvedValue();
spyOn(API, "getChatDesktopEnabled").mockResolvedValue({
enable_desktop: false,
});
spyOn(API, "updateChatDesktopEnabled").mockResolvedValue();
spyOn(API, "getUserChatCustomPrompt").mockResolvedValue({
custom_prompt: "",
});
spyOn(API, "updateUserChatCustomPrompt").mockResolvedValue({
custom_prompt: "",
});
},
} satisfies Meta<typeof SettingsPageContent>;
export default meta;
type Story = StoryObj<typeof SettingsPageContent>;
export const DesktopSetting: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText("Virtual Desktop");
await canvas.findByText(
/Allow agents to use a virtual, graphical desktop/i,
);
await canvas.findByRole("switch", { name: "Enable" });
},
};
export const TogglesDesktop: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const toggle = await canvas.findByRole("switch", {
name: "Enable",
});
await userEvent.click(toggle);
await waitFor(() => {
expect(API.updateChatDesktopEnabled).toHaveBeenCalledWith({
enable_desktop: true,
});
});
},
};
@@ -2,18 +2,22 @@ import { getErrorMessage } from "api/errors";
import {
chatCostSummary,
chatCostUsers,
chatDesktopEnabled,
chatSystemPrompt,
chatUserCustomPrompt,
updateChatDesktopEnabled,
updateChatSystemPrompt,
updateUserChatCustomPrompt,
} from "api/queries/chats";
import type * as TypesGen from "api/typesGenerated";
import { AvatarData } from "components/Avatar/AvatarData";
import { Button } from "components/Button/Button";
import { Link } from "components/Link/Link";
import { PaginationAmount } from "components/PaginationWidget/PaginationAmount";
import { PaginationWidgetBase } from "components/PaginationWidget/PaginationWidgetBase";
import { SearchField } from "components/SearchField/SearchField";
import { Spinner } from "components/Spinner/Spinner";
import { Switch } from "components/Switch/Switch";
import {
Table,
TableBody,
@@ -31,7 +35,7 @@ import {
import dayjs from "dayjs";
import { useDebouncedValue } from "hooks/debounce";
import { useClickableTableRow } from "hooks/useClickableTableRow";
import { ShieldIcon } from "lucide-react";
import { FlaskConicalIcon, ShieldIcon } from "lucide-react";
import { type FC, type FormEvent, useCallback, useMemo, useState } from "react";
import {
keepPreviousData,
@@ -63,6 +67,20 @@ const AdminBadge: FC = () => (
</TooltipProvider>
);
const ExperimentBadge: FC = () => (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex cursor-default items-center gap-1 rounded bg-surface-tertiary/60 px-1.5 py-px text-[11px] font-medium text-content-secondary">
<FlaskConicalIcon className="h-3 w-3" />
Experiment
</span>
</TooltipTrigger>
<TooltipContent side="right">Experimental feature.</TooltipContent>
</Tooltip>
</TooltipProvider>
);
const pageSize = 10;
const UserRow: FC<{
@@ -355,6 +373,13 @@ export const SettingsPageContent: FC<SettingsPageContentProps> = ({
isError: isSaveUserPromptError,
} = useMutation(updateUserChatCustomPrompt(queryClient));
const desktopEnabledQuery = useQuery(chatDesktopEnabled());
const {
mutate: saveDesktopEnabled,
isPending: isSavingDesktopEnabled,
isError: isSaveDesktopEnabledError,
} = useMutation(updateChatDesktopEnabled(queryClient));
const serverPrompt = systemPromptQuery.data?.system_prompt ?? "";
const [localEdit, setLocalEdit] = useState<string | null>(null);
const systemPromptDraft = localEdit ?? serverPrompt;
@@ -366,7 +391,9 @@ export const SettingsPageContent: FC<SettingsPageContentProps> = ({
const isSystemPromptDirty = localEdit !== null && localEdit !== serverPrompt;
const isUserPromptDirty =
localUserEdit !== null && localUserEdit !== serverUserPrompt;
const isDisabled = isSavingSystemPrompt || isSavingUserPrompt;
const desktopEnabled = desktopEnabledQuery.data?.enable_desktop ?? false;
const isDisabled =
isSavingSystemPrompt || isSavingUserPrompt || isSavingDesktopEnabled;
const handleSaveSystemPrompt = useCallback(
(event: FormEvent) => {
@@ -495,6 +522,50 @@ export const SettingsPageContent: FC<SettingsPageContentProps> = ({
</p>
)}
</form>
<hr className="my-5 border-0 border-t border-solid border-border" />
<div className="space-y-2">
<div className="flex items-center gap-2">
<h3 className="m-0 text-[13px] font-semibold text-content-primary">
Virtual Desktop
</h3>
<AdminBadge />
<ExperimentBadge />
</div>
<div className="flex items-center justify-between gap-4">
<div className="!mt-0.5 m-0 flex-1 text-xs text-content-secondary">
<p className="m-0">
Allow agents to use a virtual, graphical desktop within
workspaces. Requires the{" "}
<Link
href="https://registry.coder.com/modules/coder/portabledesktop"
target="_blank"
size="sm"
>
portabledesktop module
</Link>{" "}
to be installed in the workspace and the Anthropic
provider to be configured.
</p>
<p className="mt-2 mb-0 font-semibold text-content-secondary">
Warning: This is an experimental, in-progress feature,
and youre likely to encounter bugs if you enable it.
</p>
</div>
<Switch
checked={desktopEnabled}
onCheckedChange={(checked) =>
saveDesktopEnabled({ enable_desktop: checked })
}
aria-label="Enable"
disabled={isDisabled}
/>
</div>
{isSaveDesktopEnabledError && (
<p className="m-0 text-xs text-content-destructive">
Failed to save desktop setting.
</p>
)}
</div>
</>
)}
</>
@@ -74,6 +74,13 @@ export const EmptyState: Story = {
},
};
export const DesktopHidden: Story = {
args: {
tabs: [],
desktopChatId: undefined,
},
};
export const ExpandedWithTitle: Story = {
args: {
tabs: [gitTab],