diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 6e0144a435..1e1df13600 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -3079,6 +3079,21 @@ class ApiMethods { await this.axios.put("/api/experimental/chats/config/system-prompt", req); }; + getChatDesktopEnabled = + async (): Promise => { + const response = + await this.axios.get( + "/api/experimental/chats/config/desktop-enabled", + ); + return response.data; + }; + + updateChatDesktopEnabled = async ( + req: TypesGen.UpdateChatDesktopEnabledRequest, + ): Promise => { + await this.axios.put("/api/experimental/chats/config/desktop-enabled", req); + }; + getUserChatCustomPrompt = async (): Promise => { const response = await this.axios.get( diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index e9b01f53d7..a8067348e4 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -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 = () => ({ diff --git a/site/src/pages/AgentsPage/AgentDetail.tsx b/site/src/pages/AgentsPage/AgentDetail.tsx index be14a44d70..0c01d375dd 100644 --- a/site/src/pages/AgentsPage/AgentDetail.tsx +++ b/site/src/pages/AgentsPage/AgentDetail.tsx @@ -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 + } /> ); }; diff --git a/site/src/pages/AgentsPage/SettingsPageContent.stories.tsx b/site/src/pages/AgentsPage/SettingsPageContent.stories.tsx new file mode 100644 index 0000000000..61ce58062b --- /dev/null +++ b/site/src/pages/AgentsPage/SettingsPageContent.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +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, + }); + }); + }, +}; diff --git a/site/src/pages/AgentsPage/SettingsPageContent.tsx b/site/src/pages/AgentsPage/SettingsPageContent.tsx index d8d819ef1a..8cc82ad465 100644 --- a/site/src/pages/AgentsPage/SettingsPageContent.tsx +++ b/site/src/pages/AgentsPage/SettingsPageContent.tsx @@ -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 = () => ( ); +const ExperimentBadge: FC = () => ( + + + + + + Experiment + + + Experimental feature. + + +); + const pageSize = 10; const UserRow: FC<{ @@ -355,6 +373,13 @@ export const SettingsPageContent: FC = ({ 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(null); const systemPromptDraft = localEdit ?? serverPrompt; @@ -366,7 +391,9 @@ export const SettingsPageContent: FC = ({ 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 = ({

)} +
+
+
+

+ Virtual Desktop +

+ + +
+
+
+

+ Allow agents to use a virtual, graphical desktop within + workspaces. Requires the{" "} + + portabledesktop module + {" "} + to be installed in the workspace and the Anthropic + provider to be configured. +

+

+ Warning: This is an experimental, in-progress feature, + and you’re likely to encounter bugs if you enable it. +

+
+ + saveDesktopEnabled({ enable_desktop: checked }) + } + aria-label="Enable" + disabled={isDisabled} + /> +
+ {isSaveDesktopEnabledError && ( +

+ Failed to save desktop setting. +

+ )} +
)} diff --git a/site/src/pages/AgentsPage/SidebarTabView.stories.tsx b/site/src/pages/AgentsPage/SidebarTabView.stories.tsx index 936cc5380a..6105c49ca5 100644 --- a/site/src/pages/AgentsPage/SidebarTabView.stories.tsx +++ b/site/src/pages/AgentsPage/SidebarTabView.stories.tsx @@ -74,6 +74,13 @@ export const EmptyState: Story = { }, }; +export const DesktopHidden: Story = { + args: { + tabs: [], + desktopChatId: undefined, + }, +}; + export const ExpandedWithTitle: Story = { args: { tabs: [gitTab],