mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
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:
@@ -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>(
|
||||
|
||||
@@ -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 = () => ({
|
||||
|
||||
@@ -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 you’re 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],
|
||||
|
||||
Reference in New Issue
Block a user