From 999a4664fd429e7bbe828b2edbbd145daa6594ad Mon Sep 17 00:00:00 2001 From: Scott Weiss Date: Thu, 15 Oct 2026 17:30:30 -0400 Subject: [PATCH] feat(desktop): let device users choose managed tools in Settings Add a "Managed tools" card to the desktop Settings view, backed by the daemon's program management API. - Tauri commands `get_program_management` (GET /v1/programs) and `set_program_choice` (POST /v1/programs/choice) relay to the daemon. - The desktop model polls program management with the existing status poll and adds `setProgramChoice`, which shows the choice while saving and reports the daemon's error text when it refuses one. - When the policy allows a choice, the card offers All / Only these / None with a switch per configured tool; otherwise it lists the tools as managed by the organization. Each tool shows its last apply state, including "Opted out". - Stories cover each state, the choices sent, and the refused case. Refs #174 --- crates/agentdesktop/src/main.rs | 17 + frontend/desktop/src/Desktop.tsx | 4 + frontend/desktop/src/backend.ts | 12 + .../src/stories/Appearance.stories.tsx | 3 + .../src/stories/ManagedTools.stories.tsx | 98 ++++++ .../src/stories/StandaloneUsage.stories.tsx | 3 + frontend/desktop/src/stories/fixtures.ts | 57 ++++ frontend/desktop/src/styles.css | 80 +++++ frontend/desktop/src/types.ts | 36 +++ frontend/desktop/src/useDesktopModel.ts | 71 ++++- .../src/views/SettingsView.stories.tsx | 158 ++++++++- frontend/desktop/src/views/SettingsView.tsx | 301 +++++++++++++++++- 12 files changed, 825 insertions(+), 15 deletions(-) create mode 100644 frontend/desktop/src/stories/ManagedTools.stories.tsx diff --git a/crates/agentdesktop/src/main.rs b/crates/agentdesktop/src/main.rs index 7e4fc66e..872da8b1 100644 --- a/crates/agentdesktop/src/main.rs +++ b/crates/agentdesktop/src/main.rs @@ -21,6 +21,7 @@ use agentdesktop_core::{ config::DaemonConfig, model::{ ControllerConnectionStatus, DaemonInfo, DeviceUsage, Discovery, EnrollmentStatus, Health, + ProgramChoice, ProgramManagement, }, }; use clap::{Parser, Subcommand}; @@ -613,6 +614,20 @@ async fn logout_managed_device() -> Result<(), String> { .map_err(|error| format!("{error:#}")) } +#[tauri::command] +async fn get_program_management() -> Result { + client::get(&socket_path(), "/v1/programs") + .await + .map_err(|error| format!("{error:#}")) +} + +#[tauri::command] +async fn set_program_choice(choice: ProgramChoice) -> Result { + client::post_json_decode(&socket_path(), "/v1/programs/choice", &choice) + .await + .map_err(|error| format!("{error:#}")) +} + async fn open_enrollment_url( mut get_status: GetStatus, mut open_url: OpenUrl, @@ -730,6 +745,8 @@ fn run_desktop() -> anyhow::Result<()> { get_discovery, get_device_usage, logout_managed_device, + get_program_management, + set_program_choice, setup_managed_device ]) .run(tauri::generate_context!())?; diff --git a/frontend/desktop/src/Desktop.tsx b/frontend/desktop/src/Desktop.tsx index 1f1cbe12..76cfb06e 100644 --- a/frontend/desktop/src/Desktop.tsx +++ b/frontend/desktop/src/Desktop.tsx @@ -44,6 +44,10 @@ export function Desktop() { settings={model.settings} disabled={!model.bootstrap || model.isSaving} onStartupChange={model.setOpenOnStartup} + programManagement={model.programManagement} + programsLoading={!model.hasLoadedStatus} + savingPrograms={model.isChangingPrograms} + onProgramChoiceChange={model.setProgramChoice} /> ) : !model.hasLoadedStatus ? ( diff --git a/frontend/desktop/src/backend.ts b/frontend/desktop/src/backend.ts index b45646d4..ec9249f8 100644 --- a/frontend/desktop/src/backend.ts +++ b/frontend/desktop/src/backend.ts @@ -6,6 +6,8 @@ import type { DeviceUsage, Discovery, ManagedDeviceSnapshot, + ProgramChoice, + ProgramManagement, Settings, UsageRange, } from "./types"; @@ -45,6 +47,16 @@ export async function logoutManagedDevice(): Promise { return invoke("logout_managed_device"); } +export async function getProgramManagement(): Promise { + return invoke("get_program_management"); +} + +export async function setProgramChoice( + choice: ProgramChoice, +): Promise { + return invoke("set_program_choice", { choice }); +} + export async function setupManagedDevice(): Promise { return invoke("setup_managed_device"); } diff --git a/frontend/desktop/src/stories/Appearance.stories.tsx b/frontend/desktop/src/stories/Appearance.stories.tsx index ef7bd873..99607af7 100644 --- a/frontend/desktop/src/stories/Appearance.stories.tsx +++ b/frontend/desktop/src/stories/Appearance.stories.tsx @@ -8,6 +8,7 @@ import type { Settings } from "../types"; import { bootstrap, emptyDiscovery, + noConfiguredPrograms, pendingDevice, standaloneConnector, unconfiguredDevice, @@ -67,6 +68,8 @@ const meta = { : unconfiguredDevice; case "get_discovery": return emptyDiscovery; + case "get_program_management": + return noConfiguredPrograms; case "get_remote_config": return null; default: diff --git a/frontend/desktop/src/stories/ManagedTools.stories.tsx b/frontend/desktop/src/stories/ManagedTools.stories.tsx new file mode 100644 index 00000000..96665a2a --- /dev/null +++ b/frontend/desktop/src/stories/ManagedTools.stories.tsx @@ -0,0 +1,98 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { clearMocks, mockIPC } from "@tauri-apps/api/mocks"; +import { expect, fn, userEvent } from "storybook/test"; + +import { Desktop } from "../Desktop"; +import type { ProgramChoice, ProgramManagement } from "../types"; +import { + allProgramsManaged, + bootstrap, + emptyDiscovery, + standaloneConnector, + unconfiguredDevice, +} from "./fixtures"; + +const refusal = + "daemon returned 403 Forbidden: this device's policy manages every configured program (programSelection.allowUserChoice is off)"; + +let management: ProgramManagement; +const choiceRequest = fn(); + +const meta = { + title: "Desktop/Managed tools behavior", + component: Desktop, + tags: ["test"], + parameters: { themeProvider: false }, + beforeEach: ({ parameters }) => { + management = allProgramsManaged; + choiceRequest.mockClear(); + mockIPC((command, payload) => { + switch (command) { + case "desktop_ready": + return; + case "get_bootstrap": + return bootstrap; + case "get_connector_status": + return standaloneConnector; + case "get_managed_device_status": + return unconfiguredDevice; + case "get_discovery": + return emptyDiscovery; + case "get_program_management": + return management; + case "set_program_choice": { + const { choice } = payload as { choice: ProgramChoice }; + choiceRequest(choice); + if (parameters.choiceRefused) throw new Error(refusal); + const managed = choice.mode !== "none"; + management = { + ...management, + choice, + programs: management.programs.map((program) => ({ + program: program.program, + managed, + state: managed ? "applied" : "opted_out", + })), + }; + return management; + } + default: + throw new Error(`Unexpected desktop command: ${command}`); + } + }); + return () => clearMocks(); + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const OptsOutOfEveryTool: Story = { + play: async ({ canvas }) => { + await userEvent.click( + await canvas.findByRole("button", { name: "Settings" }), + ); + await expect( + await canvas.findByRole("radio", { name: "All" }), + ).toBeChecked(); + await userEvent.click(canvas.getByRole("radio", { name: "None" })); + await expect(choiceRequest).toHaveBeenCalledWith({ mode: "none" }); + await expect( + await canvas.findByText("Managed tools updated"), + ).toBeVisible(); + await expect(canvas.getByRole("radio", { name: "None" })).toBeChecked(); + await expect(canvas.getAllByText("Opted out")).toHaveLength(3); + }, +}; + +export const ShowsTheDaemonRefusal: Story = { + parameters: { choiceRefused: true }, + play: async ({ canvas }) => { + await userEvent.click( + await canvas.findByRole("button", { name: "Settings" }), + ); + await userEvent.click(await canvas.findByRole("radio", { name: "None" })); + await expect(await canvas.findByRole("alert")).toHaveTextContent(refusal); + await expect(canvas.getByRole("radio", { name: "All" })).toBeChecked(); + }, +}; diff --git a/frontend/desktop/src/stories/StandaloneUsage.stories.tsx b/frontend/desktop/src/stories/StandaloneUsage.stories.tsx index 0db9e9ad..f3d87be6 100644 --- a/frontend/desktop/src/stories/StandaloneUsage.stories.tsx +++ b/frontend/desktop/src/stories/StandaloneUsage.stories.tsx @@ -7,6 +7,7 @@ import { bootstrap, deviceUsage, emptyDiscovery, + noConfiguredPrograms, standaloneConnector, standaloneDaemonInfo, unconfiguredDevice, @@ -39,6 +40,8 @@ const meta = { return unconfiguredDevice; case "get_discovery": return emptyDiscovery; + case "get_program_management": + return noConfiguredPrograms; case "get_remote_config": return null; case "get_device_usage": diff --git a/frontend/desktop/src/stories/fixtures.ts b/frontend/desktop/src/stories/fixtures.ts index f4f59cab..0de0535d 100644 --- a/frontend/desktop/src/stories/fixtures.ts +++ b/frontend/desktop/src/stories/fixtures.ts @@ -7,6 +7,7 @@ import type { DeviceUsage, Discovery, ManagedDeviceSnapshot, + ProgramManagement, } from "../types"; export const bootstrap: Bootstrap = { @@ -440,3 +441,59 @@ export const usageNotCollected: DeviceUsage = { ...noDeviceUsage, enabled: false, }; + +/** Every configured program managed, as the device user chose. */ +export const allProgramsManaged: ProgramManagement = { + userChoiceAllowed: true, + choice: { mode: "all" }, + programs: [ + { program: "claude-code", managed: true, state: "unchanged" }, + { program: "codex", managed: true, state: "applied" }, + { + program: "vscode", + managed: true, + state: "conflict", + detail: "settings.json already sets a value Agent Desktop manages", + }, + ], +}; + +/** The device user manages only Claude Code and VS Code. */ +export const selectedProgramsManaged: ProgramManagement = { + userChoiceAllowed: true, + choice: { mode: "selected", programs: ["claude-code", "vscode"] }, + programs: [ + { program: "claude-code", managed: true, state: "unchanged" }, + { program: "codex", managed: false, state: "opted_out" }, + { program: "vscode", managed: true, state: "applied" }, + ], +}; + +/** The device user opted out of every configured program. */ +export const noProgramsManaged: ProgramManagement = { + userChoiceAllowed: true, + choice: { mode: "none" }, + programs: [ + { program: "claude-code", managed: false, state: "opted_out" }, + { program: "codex", managed: false, state: "opted_out" }, + { program: "vscode", managed: false, state: "opted_out" }, + ], +}; + +/** The policy does not let the device user choose. */ +export const organizationManagedPrograms: ProgramManagement = { + userChoiceAllowed: false, + choice: { mode: "all" }, + programs: [ + { program: "claude-code", managed: true, state: "unchanged" }, + { program: "codex", managed: true, state: "applied" }, + { program: "grok", managed: true }, + ], +}; + +/** No configuration, or one that configures no programs. */ +export const noConfiguredPrograms: ProgramManagement = { + userChoiceAllowed: false, + choice: { mode: "all" }, + programs: [], +}; diff --git a/frontend/desktop/src/styles.css b/frontend/desktop/src/styles.css index cb14b0f3..6c8d2b3c 100644 --- a/frontend/desktop/src/styles.css +++ b/frontend/desktop/src/styles.css @@ -711,6 +711,86 @@ p { outline-offset: 2px; } +.program-choice { + flex-wrap: wrap; +} + +.inline-preference > .program-modes { + flex-direction: row; + flex-wrap: wrap; + gap: 6px 16px; +} + +.program-mode { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--ink); + font-size: 12px; + font-weight: 500; + cursor: pointer; +} + +.program-mode input { + margin: 0; + accent-color: var(--accent-button); + cursor: inherit; +} + +.program-mode:has(input:disabled) { + color: var(--muted); + cursor: not-allowed; +} + +.program-policy { + padding: 13px 17px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.program-policy h3 { + margin: 0; + color: var(--ink); + font-size: 12px; + font-weight: 600; +} + +.program-policy p { + margin: 0; + color: var(--muted); + font-size: 10px; +} + +.program-list { + margin: 0; + padding: 0; + list-style: none; +} + +.program-policy + .program-list > .program-row:first-child { + border-top: 0; +} + +.program-row > div:first-child { + min-width: 0; +} + +.inline-preference > .program-row-controls { + flex: 0 0 auto; + flex-direction: row; + align-items: center; + gap: 12px; +} + +.program-row .switch:has(input:disabled) { + cursor: not-allowed; +} + +.program-row .switch input:disabled + .switch-track { + opacity: 0.55; +} + .advanced-config-body { padding: 14px 17px 17px; display: flex; diff --git a/frontend/desktop/src/types.ts b/frontend/desktop/src/types.ts index 1842563d..43b10438 100644 --- a/frontend/desktop/src/types.ts +++ b/frontend/desktop/src/types.ts @@ -137,3 +137,39 @@ export interface DeviceUsage { /** Configured tools whose gateway traffic is not metered. */ unmeteredTools?: string[]; } + +/** + * The configured programs the device user wants Agent Desktop to manage, + * honored only while the policy allows a user choice. Program IDs are + * provider IDs such as `claude-code`. + */ +export type ProgramChoice = + | { mode: "all" } + | { mode: "none" } + | { mode: "selected"; programs: string[] }; + +/** One configured program and its last reconcile outcome. */ +export interface ManagedProgram { + /** Provider ID, for example `claude-code`. */ + program: string; + /** False when the device user opted out of this program. */ + managed: boolean; + /** + * Outcome of the last apply (`applied`, `unchanged`, `opted_out`, …); + * absent until the program has been applied. + */ + state?: string; + detail?: string; +} + +/** + * Which configured programs are managed on this device, and whether the + * device user may change that. + */ +export interface ProgramManagement { + userChoiceAllowed: boolean; + /** The stored choice; ignored while `userChoiceAllowed` is false. */ + choice: ProgramChoice; + /** Configured programs in reconcile order; empty before a configuration. */ + programs: ManagedProgram[]; +} diff --git a/frontend/desktop/src/useDesktopModel.ts b/frontend/desktop/src/useDesktopModel.ts index 61c23e81..c5c2a3e9 100644 --- a/frontend/desktop/src/useDesktopModel.ts +++ b/frontend/desktop/src/useDesktopModel.ts @@ -2,6 +2,7 @@ import type { ColorMode } from "@agentdesktop/ui"; import { startTransition, useEffect, + useOptimistic, useRef, useState, useTransition, @@ -13,8 +14,10 @@ import { getConnectorStatus, getDiscovery, getManagedDeviceStatus, + getProgramManagement, logoutManagedDevice, saveSettings, + setProgramChoice as sendProgramChoice, setupManagedDevice, } from "./backend"; import type { @@ -22,18 +25,21 @@ import type { ConnectorSnapshot, Discovery, ManagedDeviceSnapshot, + ProgramChoice, + ProgramManagement, Settings, } from "./types"; export type View = "home" | "tools" | "usage" | "settings"; export type Notice = { tone: "success" | "error"; message: string } | null; -type StatusSource = "connector" | "managedDevice" | "discovery"; +type StatusSource = "connector" | "managedDevice" | "discovery" | "programs"; type StatusErrors = Partial>; type StatusUpdate = { connector: PromiseSettledResult; managedDevice: PromiseSettledResult; discovery: PromiseSettledResult; + programs: PromiseSettledResult; }; const loadingSettings: Settings = { openOnStartup: true, colorMode: "system" }; @@ -42,6 +48,7 @@ const statusSourceLabels: Record = { connector: "local daemon status", managedDevice: "organization status", discovery: "tool inventory", + programs: "managed tools", }; function errorMessage(error: unknown): string { @@ -49,12 +56,14 @@ function errorMessage(error: unknown): string { } async function getStatusUpdate(): Promise { - const [connector, managedDevice, discovery] = await Promise.allSettled([ - getConnectorStatus(), - getManagedDeviceStatus(), - getDiscovery(), - ]); - return { connector, managedDevice, discovery }; + const [connector, managedDevice, discovery, programs] = + await Promise.allSettled([ + getConnectorStatus(), + getManagedDeviceStatus(), + getDiscovery(), + getProgramManagement(), + ]); + return { connector, managedDevice, discovery, programs }; } function statusErrorMessage(errors: StatusErrors): string | null { @@ -75,6 +84,16 @@ export function useDesktopModel() { const [managedDevice, setManagedDevice] = useState(null); const [discovery, setDiscovery] = useState(null); + const [programManagement, setProgramManagement] = + useState(null); + // Shows a choice being saved until the daemon answers. + const [shownProgramManagement, showProgramChoice] = useOptimistic( + programManagement, + (current, choice: ProgramChoice) => current && { ...current, choice }, + ); + // Advanced when a program choice change starts and when it is saved, so a + // status update read before or during the change cannot replace its result. + const programChanges = useRef(0); const [notice, setNotice] = useState(null); const [statusErrors, setStatusErrors] = useState({}); const [hasLoadedStatus, setHasLoadedStatus] = useState(false); @@ -83,6 +102,7 @@ export function useDesktopModel() { const [isSaving, startSaving] = useTransition(); const savingSettings = useRef(false); const [isLoggingOut, startLoggingOut] = useTransition(); + const [isChangingPrograms, startChangingPrograms] = useTransition(); const needsEnrollment = Boolean( managedDevice?.configured && managedDevice.enrollment !== "approved", ); @@ -97,7 +117,8 @@ export function useDesktopModel() { if (view === "usage" && hasLoadedStatus && !showUsage) setView("home"); }, [view, hasLoadedStatus, showUsage]); - function applyStatusUpdate(update: StatusUpdate) { + /** `programChange` is `programChanges` from before `update` was read. */ + function applyStatusUpdate(update: StatusUpdate, programChange: number) { const nextErrors: StatusErrors = {}; startTransition(() => { if (update.connector.status === "fulfilled") { @@ -115,6 +136,13 @@ export function useDesktopModel() { } else { nextErrors.discovery = errorMessage(update.discovery.reason); } + if (programChange === programChanges.current) { + if (update.programs.status === "fulfilled") { + setProgramManagement(update.programs.value); + } else { + nextErrors.programs = errorMessage(update.programs.reason); + } + } setStatusErrors(nextErrors); setHasLoadedStatus(true); }); @@ -221,8 +249,9 @@ export function useDesktopModel() { let active = true; let interval: number | undefined; const refreshStatus = async () => { + const programChange = programChanges.current; const update = await getStatusUpdate(); - if (active) applyStatusUpdate(update); + if (active) applyStatusUpdate(update, programChange); }; const stopPolling = () => { if (interval === undefined) return; @@ -255,7 +284,8 @@ export function useDesktopModel() { function refresh() { setNotice(null); startRefreshing(async () => { - applyStatusUpdate(await getStatusUpdate()); + const programChange = programChanges.current; + applyStatusUpdate(await getStatusUpdate(), programChange); }); } @@ -301,6 +331,24 @@ export function useDesktopModel() { }); } + function setProgramChoice(choice: ProgramChoice) { + setNotice(null); + programChanges.current += 1; + startChangingPrograms(async () => { + showProgramChoice(choice); + try { + const nextProgramManagement = await sendProgramChoice(choice); + programChanges.current += 1; + startTransition(() => { + setProgramManagement(nextProgramManagement); + }); + setNotice({ tone: "success", message: "Managed tools updated" }); + } catch (error: unknown) { + setNotice({ tone: "error", message: errorMessage(error) }); + } + }); + } + function updateSettings(patch: Partial) { if (!bootstrap || savingSettings.current) return; savingSettings.current = true; @@ -369,6 +417,7 @@ export function useDesktopModel() { discovery, enroll, hasLoadedStatus, + isChangingPrograms, isLoggingOut, isManaging, isRefreshing, @@ -379,10 +428,12 @@ export function useDesktopModel() { needsEnrollment, notice, pageTitle, + programManagement: shownProgramManagement, refresh, refreshError: statusErrorMessage(statusErrors), setColorMode, setOpenOnStartup, + setProgramChoice, settings, showUsage, view, diff --git a/frontend/desktop/src/views/SettingsView.stories.tsx b/frontend/desktop/src/views/SettingsView.stories.tsx index a4c45acb..cb9adef0 100644 --- a/frontend/desktop/src/views/SettingsView.stories.tsx +++ b/frontend/desktop/src/views/SettingsView.stories.tsx @@ -1,9 +1,16 @@ import { SYSTEM_THEME_QUERY } from "@agentdesktop/ui"; import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, fn, userEvent } from "storybook/test"; +import { expect, fn, userEvent, within } from "storybook/test"; import { DesktopStoryFrame } from "../stories/DesktopStoryFrame"; -import { bootstrap } from "../stories/fixtures"; +import { + allProgramsManaged, + bootstrap, + noConfiguredPrograms, + noProgramsManaged, + organizationManagedPrograms, + selectedProgramsManaged, +} from "../stories/fixtures"; import { SettingsView } from "./SettingsView"; const meta = { @@ -21,6 +28,10 @@ const meta = { settings: bootstrap.settings, disabled: false, onStartupChange: fn(), + programManagement: allProgramsManaged, + programsLoading: false, + savingPrograms: false, + onProgramChoiceChange: fn(), }, } satisfies Meta; @@ -89,3 +100,146 @@ export const DarkReflow: Story = { viewport: { value: "reflow", isRotated: false }, }, }; + +function managedTools(canvasElement: HTMLElement) { + const heading = within(canvasElement).getByRole("heading", { + name: "Managed tools", + }); + const card = heading.closest("section"); + if (!card) throw new Error("Managed tools card not found"); + return within(card); +} + +export const ManagesAllTools: Story = { + play: async ({ canvasElement }) => { + const card = managedTools(canvasElement); + await expect(card.getByRole("radio", { name: "All" })).toBeChecked(); + for (const name of ["Claude Code", "Codex", "VS Code"]) { + const program = card.getByRole("checkbox", { name }); + await expect(program).toBeChecked(); + await expect(program).toBeDisabled(); + } + await expect( + card.getByRole("checkbox", { name: "VS Code" }), + ).toHaveAccessibleDescription( + "Conflict settings.json already sets a value Agent Desktop manages", + ); + }, +}; + +export const ManagesSelectedTools: Story = { + args: { programManagement: selectedProgramsManaged }, + play: async ({ canvasElement }) => { + const card = managedTools(canvasElement); + await expect(card.getByRole("radio", { name: "Only these" })).toBeChecked(); + await expect( + card.getByRole("checkbox", { name: "Claude Code" }), + ).toBeChecked(); + const codex = card.getByRole("checkbox", { name: "Codex" }); + await expect(codex).not.toBeChecked(); + await expect(codex).toBeEnabled(); + await expect(codex).toHaveAccessibleDescription("Opted out"); + }, +}; + +export const ManagesNoTools: Story = { + args: { programManagement: noProgramsManaged }, + play: async ({ canvasElement }) => { + const card = managedTools(canvasElement); + await expect(card.getByRole("radio", { name: "None" })).toBeChecked(); + for (const name of ["Claude Code", "Codex", "VS Code"]) { + await expect(card.getByRole("checkbox", { name })).not.toBeChecked(); + } + await expect(card.getAllByText("Opted out")).toHaveLength(3); + }, +}; + +export const ChoosesManagedTools: Story = { + args: { programManagement: selectedProgramsManaged }, + play: async ({ args, canvasElement }) => { + const card = managedTools(canvasElement); + await userEvent.click(card.getByRole("checkbox", { name: "Codex" })); + await expect(args.onProgramChoiceChange).toHaveBeenLastCalledWith({ + mode: "selected", + programs: ["claude-code", "codex", "vscode"], + }); + await userEvent.click(card.getByRole("checkbox", { name: "VS Code" })); + await expect(args.onProgramChoiceChange).toHaveBeenLastCalledWith({ + mode: "selected", + programs: ["claude-code"], + }); + await userEvent.click(card.getByRole("radio", { name: "None" })); + await expect(args.onProgramChoiceChange).toHaveBeenLastCalledWith({ + mode: "none", + }); + await userEvent.click(card.getByRole("radio", { name: "All" })); + await expect(args.onProgramChoiceChange).toHaveBeenLastCalledWith({ + mode: "all", + }); + }, +}; + +export const ChoosesOnlyTheseFromAll: Story = { + play: async ({ args, canvasElement }) => { + const card = managedTools(canvasElement); + await userEvent.click(card.getByRole("radio", { name: "Only these" })); + await expect(args.onProgramChoiceChange).toHaveBeenCalledWith({ + mode: "selected", + programs: ["claude-code", "codex", "vscode"], + }); + }, +}; + +export const ManagedByOrganization: Story = { + args: { programManagement: organizationManagedPrograms }, + play: async ({ canvasElement }) => { + const card = managedTools(canvasElement); + const list = card.getByRole("list", { + name: "Managed by your organization", + }); + await expect(within(list).getAllByRole("listitem")).toHaveLength(3); + await expect(within(list).getByText("Grok Build")).toBeVisible(); + await expect(within(list).getByText("Pending")).toBeVisible(); + await expect(card.queryByRole("radio")).toBeNull(); + await expect(card.queryByRole("checkbox")).toBeNull(); + }, +}; + +export const SavingManagedTools: Story = { + args: { programManagement: selectedProgramsManaged, savingPrograms: true }, + play: async ({ canvasElement }) => { + const card = managedTools(canvasElement); + await expect(card.getByRole("status")).toHaveTextContent("Saving…"); + for (const radio of card.getAllByRole("radio")) { + await expect(radio).toBeDisabled(); + } + for (const checkbox of card.getAllByRole("checkbox")) { + await expect(checkbox).toBeDisabled(); + } + }, +}; + +export const NoConfiguredTools: Story = { + args: { programManagement: noConfiguredPrograms }, + play: async ({ canvasElement }) => { + const card = managedTools(canvasElement); + await expect(card.getByText("No tools are configured")).toBeVisible(); + await expect(card.queryByRole("list")).toBeNull(); + }, +}; + +export const ManagedToolsUnavailable: Story = { + args: { programManagement: null }, + play: async ({ canvasElement }) => { + const card = managedTools(canvasElement); + await expect(card.getByText("Managed tools are unavailable")).toBeVisible(); + }, +}; + +export const ManagedToolsDarkReflow: Story = { + args: { programManagement: selectedProgramsManaged }, + globals: { + colorMode: "dark", + viewport: { value: "reflow", isRotated: false }, + }, +}; diff --git a/frontend/desktop/src/views/SettingsView.tsx b/frontend/desktop/src/views/SettingsView.tsx index bddb2287..b0e5e44d 100644 --- a/frontend/desktop/src/views/SettingsView.tsx +++ b/frontend/desktop/src/views/SettingsView.tsx @@ -1,16 +1,305 @@ -import { AppearancePreference, CardHeader } from "@agentdesktop/ui"; -import { useId } from "react"; +import { + AppearancePreference, + CardHeader, + friendlyTool, + ToolIcon, +} from "@agentdesktop/ui"; +import { AlertCircle, Box, LoaderCircle } from "lucide-react"; +import { type ReactNode, useId } from "react"; -import type { Settings } from "../types"; +import type { + ManagedProgram, + ProgramChoice, + ProgramManagement, + Settings, +} from "../types"; + +type Tone = "success" | "warning" | "danger" | "neutral"; + +const programStates: Record = { + applied: { label: "Applied", tone: "success" }, + unchanged: { label: "Up to date", tone: "success" }, + removed: { label: "Removed", tone: "neutral" }, + opted_out: { label: "Opted out", tone: "neutral" }, + inactive: { label: "Inactive", tone: "warning" }, + blocked: { label: "Blocked", tone: "warning" }, + conflict: { label: "Conflict", tone: "danger" }, + failed: { label: "Failed", tone: "danger" }, +}; + +const choiceModes: Array<{ + mode: ProgramChoice["mode"]; + label: string; + description: string; +}> = [ + { + mode: "all", + label: "All", + description: "Every configured tool, including tools configured later.", + }, + { + mode: "selected", + label: "Only these", + description: + "The tools switched on below. Tools configured later stay unmanaged until you switch them on.", + }, + { + mode: "none", + label: "None", + description: + "Agent Desktop removes the settings it wrote from every configured tool.", + }, +]; + +function programStatus(program: ManagedProgram): { label: string; tone: Tone } { + if (program.state) { + return ( + programStates[program.state] ?? { label: program.state, tone: "neutral" } + ); + } + return { label: program.managed ? "Pending" : "Opted out", tone: "neutral" }; +} + +function includes(choice: ProgramChoice, program: string): boolean { + return ( + choice.mode === "all" || + (choice.mode === "selected" && choice.programs.includes(program)) + ); +} + +function selection(programs: Iterable): ProgramChoice { + return { mode: "selected", programs: [...new Set(programs)].sort() }; +} + +/** A configured program; with `toggle`, a switch to manage it. */ +function ProgramRow({ + program, + toggle, +}: { + program: ManagedProgram; + toggle?: { + checked: boolean; + disabled: boolean; + onChange: (checked: boolean) => void; + }; +}) { + const id = useId(); + const status = programStatus(program); + return ( +
  • +
    + {toggle ? ( + + + + ) : ( + + + {friendlyTool(program.program)} + + )} + {program.detail ? ( + {program.detail} + ) : null} +
    +
    + + {status.label} + + {toggle ? ( + + ) : null} +
    +
  • + ); +} + +function ManagedTools({ + management, + loading, + saving, + onChoiceChange, +}: { + management: ProgramManagement | null; + loading: boolean; + saving: boolean; + onChoiceChange: (choice: ProgramChoice) => void; +}) { + const id = useId(); + const allowed = Boolean(management?.userChoiceAllowed); + let body: ReactNode; + + if (!management) { + body = loading ? ( +
    + +
    + Checking managed tools +
    +
    + ) : ( +
    + +
    + Managed tools are unavailable + Use Refresh to try again. +
    +
    + ); + } else if (!management.programs.length) { + body = ( +
    + +
    + No tools are configured + + This device’s configuration doesn’t set up any tools for Agent + Desktop to manage. + +
    +
    + ); + } else if (!allowed) { + body = ( + <> +
    +

    Managed by your organization

    +

    Your organization’s policy manages every tool it configures.

    +
    +
      + {management.programs.map((program) => ( + + ))} +
    + + ); + } else { + const { choice, programs } = management; + const chooseMode = (mode: ProgramChoice["mode"]) => + onChoiceChange( + mode === "selected" + ? selection( + programs + .map((program) => program.program) + .filter((program) => includes(choice, program)), + ) + : { mode }, + ); + const chooseProgram = (program: string, managed: boolean) => { + const current = choice.mode === "selected" ? choice.programs : []; + onChoiceChange( + selection( + managed + ? [...current, program] + : current.filter((selected) => selected !== program), + ), + ); + }; + body = ( + <> +
    +
    + Tools to manage + + { + choiceModes.find((option) => option.mode === choice.mode) + ?.description + } + +
    +
    + {choiceModes.map((option) => ( + + ))} +
    +
    +
      + {programs.map((program) => ( + chooseProgram(program.program, managed), + }} + /> + ))} +
    + + ); + } + + return ( +
    + + Saving… + + ) : null + } + /> + {body} +
    + ); +} export function SettingsView({ settings, disabled, onStartupChange, + programManagement, + programsLoading, + savingPrograms, + onProgramChoiceChange, }: { settings: Settings; disabled: boolean; onStartupChange: (checked: boolean) => void; + programManagement: ProgramManagement | null; + programsLoading: boolean; + savingPrograms: boolean; + onProgramChoiceChange: (choice: ProgramChoice) => void; }) { const startupId = useId(); return ( @@ -51,6 +340,12 @@ export function SettingsView({ + ); }