From 0f26a81889564f3aca4f4743b48e7827936418b4 Mon Sep 17 00:00:00 2001 From: Scott Weiss Date: Thu, 15 Oct 2026 17:23:54 -0400 Subject: [PATCH] feat(controller-web): show opted-out programs and build programSelection - Device page: the "Managed programs" card labels the `opted_out` state "Opted out" with a neutral badge and a title explaining that the device user opted out of the program. - Configuration builder: a "Program selection" section with a "Let device users choose which programs are managed" checkbox. When checked, the generated YAML includes `programSelection: { allowUserChoice: true }`, and a notice warns that daemons predating the setting reject it. The checkbox is seeded from the active daemon configuration. - Rename the `.sandbox-compatibility` notice style to `.config-notice` so both sections share it. - Stories: a device with an opted-out program, a configuration seeded with `programSelection`, and an interaction test that toggling the checkbox adds and removes the key. Refs #174 --- frontend/controller/src/stories/fixtures.ts | 28 +++++++++++ frontend/controller/src/styles.css | 4 +- frontend/controller/src/types.ts | 4 ++ .../src/views/ConfigurationView.stories.tsx | 39 +++++++++++++++ .../src/views/ConfigurationView.tsx | 48 ++++++++++++++++++- .../src/views/DeviceView.stories.tsx | 12 +++++ frontend/controller/src/views/DeviceView.tsx | 9 +++- 7 files changed, 140 insertions(+), 4 deletions(-) diff --git a/frontend/controller/src/stories/fixtures.ts b/frontend/controller/src/stories/fixtures.ts index 4d84b64a..508713f6 100644 --- a/frontend/controller/src/stories/fixtures.ts +++ b/frontend/controller/src/stories/fixtures.ts @@ -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: [], @@ -646,6 +666,14 @@ export const copilotFailOpenDaemonConfig: DaemonConfigDocument = { }, }; +export const programSelectionDaemonConfig: DaemonConfigDocument = { + programSelection: { allowUserChoice: true }, + programs: { + claudeCode: {}, + codex: {}, + }, +}; + export const sandboxDaemonConfig: DaemonConfigDocument = { sandbox: { network: { diff --git a/frontend/controller/src/styles.css b/frontend/controller/src/styles.css index 6ee4cf72..f2af1297 100644 --- a/frontend/controller/src/styles.css +++ b/frontend/controller/src/styles.css @@ -894,7 +894,7 @@ code { margin-top: 16px; } -.sandbox-compatibility { +.config-notice { margin: 12px 0 0; padding: 9px 10px; display: flex; @@ -908,7 +908,7 @@ code { line-height: 1.45; } -.sandbox-compatibility svg { +.config-notice svg { flex: 0 0 auto; margin-top: 1px; } diff --git a/frontend/controller/src/types.ts b/frontend/controller/src/types.ts index 8cdae11e..b1fa60a0 100644 --- a/frontend/controller/src/types.ts +++ b/frontend/controller/src/types.ts @@ -32,6 +32,7 @@ export type ProgramStatus = { | "conflict" | "inactive" | "blocked" + | "opted_out" | "failed" | "unspecified"; detail: string; @@ -156,6 +157,9 @@ export type DaemonConfigDocument = { events?: string[]; }; programs?: Partial>>; + programSelection?: { + allowUserChoice?: boolean; + }; }; export type ActiveDaemonConfig = { diff --git a/frontend/controller/src/views/ConfigurationView.stories.tsx b/frontend/controller/src/views/ConfigurationView.stories.tsx index 8009e356..91c05611 100644 --- a/frontend/controller/src/views/ConfigurationView.stories.tsx +++ b/frontend/controller/src/views/ConfigurationView.stories.tsx @@ -5,6 +5,7 @@ import { ControllerStoryFrame } from "../stories/ControllerStoryFrame"; import { activeDaemonConfig, copilotFailOpenDaemonConfig, + programSelectionDaemonConfig, sandboxDaemonConfig, } from "../stories/fixtures"; import { ConfigurationView } from "./ConfigurationView"; @@ -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 }) => { @@ -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 }) => { diff --git a/frontend/controller/src/views/ConfigurationView.tsx b/frontend/controller/src/views/ConfigurationView.tsx index 0722225e..77742929 100644 --- a/frontend/controller/src/views/ConfigurationView.tsx +++ b/frontend/controller/src/views/ConfigurationView.tsx @@ -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([ { kind: "claudeCode", useGateway: true, settings: "" }, ]); @@ -62,6 +63,7 @@ export function ConfigurationView({ sessionNewTelemetry, toolUseTelemetry, toolInputTelemetry, + allowUserChoice, agents, }); const availableAgents = configurableAgents.filter( @@ -98,6 +100,9 @@ export function ConfigurationView({ setDeniedPaths( (initialConfig.sandbox?.filesystem?.denied ?? []).join("\n"), ); + setAllowUserChoice( + initialConfig.programSelection?.allowUserChoice === true, + ); setAgents(agentDrafts(initialConfig.programs)); }, [initialConfig]); @@ -274,7 +279,7 @@ export function ConfigurationView({ /> {sandboxUnavailable && ( -

+

Remove {incompatibleSandboxAgentNames.join(" and ")} to enable sandboxing. @@ -385,6 +390,43 @@ export function ConfigurationView({ +

+ + + Program selection + Decide whether device users can opt out. + + + +
+ + {allowUserChoice && ( +

+ + Daemons that predate this setting reject a configuration that + contains it. Upgrade devices first. +

+ )} +
+
0}> @@ -609,6 +651,7 @@ function daemonConfigYaml(options: { sessionNewTelemetry: boolean; toolUseTelemetry: boolean; toolInputTelemetry: boolean; + allowUserChoice: boolean; agents: AgentDraft[]; }) { const lines: string[] = []; @@ -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 { diff --git a/frontend/controller/src/views/DeviceView.stories.tsx b/frontend/controller/src/views/DeviceView.stories.tsx index 3e3ef755..0d55b9d0 100644 --- a/frontend/controller/src/views/DeviceView.stories.tsx +++ b/frontend/controller/src/views/DeviceView.stories.tsx @@ -9,6 +9,7 @@ import { emptyUsageReport, failedDeviceDetail, oldAgentDeviceDetail, + optedOutDeviceDetail, } from "../stories/fixtures"; import { DeviceView } from "./DeviceView"; @@ -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 }) => { diff --git a/frontend/controller/src/views/DeviceView.tsx b/frontend/controller/src/views/DeviceView.tsx index fb357ca4..e08254fd 100644 --- a/frontend/controller/src/views/DeviceView.tsx +++ b/frontend/controller/src/views/DeviceView.tsx @@ -414,10 +414,17 @@ const programStateLabels: Record = { conflict: "Conflict", inactive: "Inactive", blocked: "Blocked", + opted_out: "Opted out", failed: "Failed", unspecified: "Unknown", }; +const programStateDescriptions: Partial< + Record +> = { + opted_out: "The device user opted out of this program", +}; + function ProgramBadge({ state }: { state: ProgramStatus["state"] }) { const tone = state === "conflict" || state === "failed" || state === "blocked" @@ -426,7 +433,7 @@ function ProgramBadge({ state }: { state: ProgramStatus["state"] }) { ? "warning" : "neutral"; return ( - + {programStateLabels[state] ?? state} );