Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions crates/agentdesktop/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ use agentdesktop_core::{
config::DaemonConfig,
model::{
ControllerConnectionStatus, DaemonInfo, DeviceUsage, Discovery, EnrollmentStatus, Health,
ProgramChoice, ProgramManagement,
},
};
use clap::{Parser, Subcommand};
Expand Down Expand Up @@ -613,6 +614,20 @@ async fn logout_managed_device() -> Result<(), String> {
.map_err(|error| format!("{error:#}"))
}

#[tauri::command]
async fn get_program_management() -> Result<ProgramManagement, String> {
client::get(&socket_path(), "/v1/programs")
.await
.map_err(|error| format!("{error:#}"))
}

#[tauri::command]
async fn set_program_choice(choice: ProgramChoice) -> Result<ProgramManagement, String> {
client::post_json_decode(&socket_path(), "/v1/programs/choice", &choice)
.await
.map_err(|error| format!("{error:#}"))
}

async fn open_enrollment_url<GetStatus, StatusFuture, OpenUrl>(
mut get_status: GetStatus,
mut open_url: OpenUrl,
Expand Down Expand Up @@ -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!())?;
Expand Down
4 changes: 4 additions & 0 deletions frontend/desktop/src/Desktop.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ? (
<StatusLoading view={model.view} />
Expand Down
12 changes: 12 additions & 0 deletions frontend/desktop/src/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import type {
DeviceUsage,
Discovery,
ManagedDeviceSnapshot,
ProgramChoice,
ProgramManagement,
Settings,
UsageRange,
} from "./types";
Expand Down Expand Up @@ -45,6 +47,16 @@ export async function logoutManagedDevice(): Promise<void> {
return invoke<void>("logout_managed_device");
}

export async function getProgramManagement(): Promise<ProgramManagement> {
return invoke<ProgramManagement>("get_program_management");
}

export async function setProgramChoice(
choice: ProgramChoice,
): Promise<ProgramManagement> {
return invoke<ProgramManagement>("set_program_choice", { choice });
}

export async function setupManagedDevice(): Promise<ManagedDeviceSnapshot> {
return invoke<ManagedDeviceSnapshot>("setup_managed_device");
}
3 changes: 3 additions & 0 deletions frontend/desktop/src/stories/Appearance.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { Settings } from "../types";
import {
bootstrap,
emptyDiscovery,
noConfiguredPrograms,
pendingDevice,
standaloneConnector,
unconfiguredDevice,
Expand Down Expand Up @@ -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:
Expand Down
98 changes: 98 additions & 0 deletions frontend/desktop/src/stories/ManagedTools.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof Desktop>;

export default meta;
type Story = StoryObj<typeof meta>;

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();
},
};
3 changes: 3 additions & 0 deletions frontend/desktop/src/stories/StandaloneUsage.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
bootstrap,
deviceUsage,
emptyDiscovery,
noConfiguredPrograms,
standaloneConnector,
standaloneDaemonInfo,
unconfiguredDevice,
Expand Down Expand Up @@ -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":
Expand Down
57 changes: 57 additions & 0 deletions frontend/desktop/src/stories/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
DeviceUsage,
Discovery,
ManagedDeviceSnapshot,
ProgramManagement,
} from "../types";

export const bootstrap: Bootstrap = {
Expand Down Expand Up @@ -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: [],
};
80 changes: 80 additions & 0 deletions frontend/desktop/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading