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
28 changes: 28 additions & 0 deletions frontend/controller/src/stories/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,26 @@ export const failedDeviceDetail: DeviceDetail = {
],
};

export const optedOutDeviceDetail: DeviceDetail = {
...deviceDetail,
programs: [
{
program: "claude-code",
state: "applied",
detail: "",
revision: 12,
updated_at: now - 60,
},
{
program: "codex",
state: "opted_out",
detail: "",
revision: 12,
updated_at: now - 60,
},
],
};

export const oldAgentDeviceDetail: DeviceDetail = {
...deviceDetail,
programs: [],
Expand Down Expand Up @@ -646,6 +666,14 @@ export const copilotFailOpenDaemonConfig: DaemonConfigDocument = {
},
};

export const programSelectionDaemonConfig: DaemonConfigDocument = {
programSelection: { allowUserChoice: true },
programs: {
claudeCode: {},
codex: {},
},
};

export const sandboxDaemonConfig: DaemonConfigDocument = {
sandbox: {
network: {
Expand Down
4 changes: 2 additions & 2 deletions frontend/controller/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -894,7 +894,7 @@ code {
margin-top: 16px;
}

.sandbox-compatibility {
.config-notice {
margin: 12px 0 0;
padding: 9px 10px;
display: flex;
Expand All @@ -908,7 +908,7 @@ code {
line-height: 1.45;
}

.sandbox-compatibility svg {
.config-notice svg {
flex: 0 0 auto;
margin-top: 1px;
}
Expand Down
4 changes: 4 additions & 0 deletions frontend/controller/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export type ProgramStatus = {
| "conflict"
| "inactive"
| "blocked"
| "opted_out"
| "failed"
| "unspecified";
detail: string;
Expand Down Expand Up @@ -156,6 +157,9 @@ export type DaemonConfigDocument = {
events?: string[];
};
programs?: Partial<Record<AgentKind, Record<string, unknown>>>;
programSelection?: {
allowUserChoice?: boolean;
};
};

export type ActiveDaemonConfig = {
Expand Down
39 changes: 39 additions & 0 deletions frontend/controller/src/views/ConfigurationView.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ControllerStoryFrame } from "../stories/ControllerStoryFrame";
import {
activeDaemonConfig,
copilotFailOpenDaemonConfig,
programSelectionDaemonConfig,
sandboxDaemonConfig,
} from "../stories/fixtures";
import { ConfigurationView } from "./ConfigurationView";
Expand Down Expand Up @@ -70,6 +71,39 @@ export const CopilotFailOpen: Story = {
},
};

export const ActiveProgramSelection: Story = {
args: { initialConfig: programSelectionDaemonConfig },
play: async ({ canvas, canvasElement }) => {
await expect(
canvas.getByRole("checkbox", {
name: /Let device users choose which programs are managed/,
}),
).toBeChecked();
await expect(
canvasElement.querySelector(".output-card code")?.textContent,
).toContain("programSelection:\n allowUserChoice: true\n\nprograms:");
},
};

export const BuildsProgramSelectionYaml: Story = {
play: async ({ canvas, canvasElement }) => {
const output = canvasElement.querySelector(".output-card code");
await expect(output).not.toHaveTextContent("programSelection");
await userEvent.click(canvas.getByText("Program selection"));
const toggle = canvas.getByRole("checkbox", {
name: /Let device users choose which programs are managed/,
});
await userEvent.click(toggle);
await expect(output?.textContent).toContain(
"programSelection:\n allowUserChoice: true",
);
await expect(toggle).toHaveAccessibleDescription(/Upgrade devices first/);
await userEvent.click(toggle);
await expect(output).not.toHaveTextContent("programSelection");
await expect(canvas.queryByText(/Upgrade devices first/)).toBeNull();
},
};

export const NoProxyPolicyWithoutCopilot: Story = {
args: { initialConfig: activeDaemonConfig },
play: async ({ canvas }) => {
Expand Down Expand Up @@ -179,6 +213,11 @@ export const Dark: Story = {
globals: { colorMode: "dark" },
};

export const DarkProgramSelection: Story = {
...ActiveProgramSelection,
globals: { colorMode: "dark" },
};

export const DarkAgentMenu: Story = {
globals: { colorMode: "dark" },
play: async ({ canvas }) => {
Expand Down
48 changes: 47 additions & 1 deletion frontend/controller/src/views/ConfigurationView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export function ConfigurationView({
const [allowedDomains, setAllowedDomains] = useState("");
const [writablePaths, setWritablePaths] = useState("");
const [deniedPaths, setDeniedPaths] = useState("");
const [allowUserChoice, setAllowUserChoice] = useState(false);
const [agents, setAgents] = useState<AgentDraft[]>([
{ kind: "claudeCode", useGateway: true, settings: "" },
]);
Expand Down Expand Up @@ -62,6 +63,7 @@ export function ConfigurationView({
sessionNewTelemetry,
toolUseTelemetry,
toolInputTelemetry,
allowUserChoice,
agents,
});
const availableAgents = configurableAgents.filter(
Expand Down Expand Up @@ -98,6 +100,9 @@ export function ConfigurationView({
setDeniedPaths(
(initialConfig.sandbox?.filesystem?.denied ?? []).join("\n"),
);
setAllowUserChoice(
initialConfig.programSelection?.allowUserChoice === true,
);
setAgents(agentDrafts(initialConfig.programs));
}, [initialConfig]);

Expand Down Expand Up @@ -274,7 +279,7 @@ export function ConfigurationView({
/>
</label>
{sandboxUnavailable && (
<p className="sandbox-compatibility" id="sandbox-compatibility">
<p className="config-notice" id="sandbox-compatibility">
<CircleAlert size={14} />
Remove {incompatibleSandboxAgentNames.join(" and ")} to enable
sandboxing.
Expand Down Expand Up @@ -385,6 +390,43 @@ export function ConfigurationView({
</div>
</div>
</details>
<details className="wizard-section" open={allowUserChoice}>
<summary className="wizard-section-summary">
<span className="wizard-section-title">
<strong>Program selection</strong>
<small>Decide whether device users can opt out.</small>
</span>
<ChevronRight size={15} />
</summary>
<div className="wizard-section-content">
<label className="toggle-row">
<span>
<strong>
Let device users choose which programs are managed
</strong>
<small>
Users can opt out of configured programs on their device.
Agentdesktop then removes the settings it wrote for them.
</small>
</span>
<input
type="checkbox"
checked={allowUserChoice}
aria-describedby={
allowUserChoice ? "program-selection-upgrade" : undefined
}
onChange={(event) => setAllowUserChoice(event.target.checked)}
/>
</label>
{allowUserChoice && (
<p className="config-notice" id="program-selection-upgrade">
<CircleAlert size={14} />
Daemons that predate this setting reject a configuration that
contains it. Upgrade devices first.
</p>
)}
</div>
</details>
<details className="wizard-section" open={agents.length > 0}>
<summary className="wizard-section-summary">
<span className="wizard-section-title">
Expand Down Expand Up @@ -609,6 +651,7 @@ function daemonConfigYaml(options: {
sessionNewTelemetry: boolean;
toolUseTelemetry: boolean;
toolInputTelemetry: boolean;
allowUserChoice: boolean;
agents: AgentDraft[];
}) {
const lines: string[] = [];
Expand Down Expand Up @@ -651,6 +694,9 @@ function daemonConfigYaml(options: {
}
lines.push("");
}
if (options.allowUserChoice) {
lines.push("programSelection:", " allowUserChoice: true", "");
}
if (options.agents.length === 0) {
lines.push("programs: {}");
} else {
Expand Down
12 changes: 12 additions & 0 deletions frontend/controller/src/views/DeviceView.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
emptyUsageReport,
failedDeviceDetail,
oldAgentDeviceDetail,
optedOutDeviceDetail,
} from "../stories/fixtures";
import { DeviceView } from "./DeviceView";

Expand Down Expand Up @@ -77,6 +78,17 @@ export const ConfigurationFailed: Story = {
},
};

export const ProgramOptedOut: Story = {
args: { device: optedOutDeviceDetail },
play: async ({ canvas }) => {
await expect(canvas.getByText("Opted out")).toHaveAttribute(
"title",
"The device user opted out of this program",
);
await expect(canvas.getByText("Applied")).not.toHaveAttribute("title");
},
};

export const OlderAgentWithoutProgramStatus: Story = {
args: { device: oldAgentDeviceDetail },
play: async ({ canvas }) => {
Expand Down
9 changes: 8 additions & 1 deletion frontend/controller/src/views/DeviceView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -414,10 +414,17 @@ const programStateLabels: Record<ProgramStatus["state"], string> = {
conflict: "Conflict",
inactive: "Inactive",
blocked: "Blocked",
opted_out: "Opted out",
failed: "Failed",
unspecified: "Unknown",
};

const programStateDescriptions: Partial<
Record<ProgramStatus["state"], string>
> = {
opted_out: "The device user opted out of this program",
};

function ProgramBadge({ state }: { state: ProgramStatus["state"] }) {
const tone =
state === "conflict" || state === "failed" || state === "blocked"
Expand All @@ -426,7 +433,7 @@ function ProgramBadge({ state }: { state: ProgramStatus["state"] }) {
? "warning"
: "neutral";
return (
<span className={`badge ${tone}`}>
<span className={`badge ${tone}`} title={programStateDescriptions[state]}>
{programStateLabels[state] ?? state}
</span>
);
Expand Down
Loading