From 9c5fffe95dcf605c28fb3127596b4aefeb60bd6c Mon Sep 17 00:00:00 2001 From: Adam Eivy Date: Thu, 3 Sep 2026 20:20:51 +0000 Subject: [PATCH 1/2] feat(cos): rename per-app overrides to per-app options and clarify the run toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-app section of a scheduled task read as "overrides," which made its toggle look like an "apply my overrides" flag rather than what it is: the switch that decides whether the task runs for that app at all. Every other control on the row is inert until it is on. - Rename the surface to "Per-app options" — the task drawer tab, the PerAppOverrideList heading, the Workflow timeline expander and panel label, and the app-tasks section blurb. The drawer tab id stays `overrides` so existing ?taskTab= deep links keep resolving. - Label the row switch "Run" and give it an accessible name and tooltip naming the task, the app, and the current state ("Run feature-ideas for Acme: off"), replacing the ambiguous "Enable feature-ideas for Acme". - Spell the toggle's meaning out in the section subtitle, and widen the bulk button from "Enable All" to "Enable for all apps". - Apply the same treatment to Edit App -> Automation, which shows the same data per task: "Task Type Overrides" becomes "Scheduled Task Options" with a subtitle, and its previously unlabelled per-task switch gains the same "Run" label, accessible name, and tooltip. Claude-Session: https://claude.ai/code/session_01VNAXigngmBpiD2HJzMWRiz --- .../components/apps/tabs/AutomationTab.jsx | 27 +++++++++--- .../apps/tabs/AutomationTab.test.jsx | 16 ++++---- .../src/components/cos/tabs/WorkflowTab.jsx | 12 +++--- .../cos/tabs/WorkflowTab.providers.test.jsx | 2 +- .../cos/tabs/schedule/AppOverrideRow.jsx | 41 +++++++++++-------- .../cos/tabs/schedule/AppOverrideRow.test.jsx | 28 +++++++++++++ .../cos/tabs/schedule/AppTaskTypeSection.jsx | 2 +- .../cos/tabs/schedule/PerAppOverrideList.jsx | 16 ++++++-- .../cos/tabs/schedule/TaskConfigDrawer.jsx | 9 ++-- .../tabs/schedule/TaskConfigDrawer.test.jsx | 12 +++--- 10 files changed, 112 insertions(+), 53 deletions(-) diff --git a/client/src/components/apps/tabs/AutomationTab.jsx b/client/src/components/apps/tabs/AutomationTab.jsx index 82d73be830..454ff132ff 100644 --- a/client/src/components/apps/tabs/AutomationTab.jsx +++ b/client/src/components/apps/tabs/AutomationTab.jsx @@ -209,10 +209,13 @@ export default function AutomationTab({ appId, appName }) {
-

Task Type Overrides

-

Per-app automation preferences for CoS task scheduling

+

Scheduled Task Options

+

+ Each toggle turns that CoS scheduled task on or off for this app. The controls beside it are optional — + leave one on Inherit and it follows the global schedule defaults. +

- +
{model.flexible.filter(node => expandedIds.has(node.id) && node.kind === 'task' && (node.totalAppCount || 0) > 0).map(node => (
-
{node.label} · app overrides
+
{node.label} · per-app options
))} diff --git a/client/src/components/cos/tabs/WorkflowTab.providers.test.jsx b/client/src/components/cos/tabs/WorkflowTab.providers.test.jsx index 10f6522b3b..50631014d0 100644 --- a/client/src/components/cos/tabs/WorkflowTab.providers.test.jsx +++ b/client/src/components/cos/tabs/WorkflowTab.providers.test.jsx @@ -61,7 +61,7 @@ const renderTab = async (providers) => { await act(async () => { render(); }); - const expand = await screen.findByRole('button', { name: /show app overrides for ux/i }); + const expand = await screen.findByRole('button', { name: /show per-app options for ux/i }); fireEvent.click(expand); }; diff --git a/client/src/components/cos/tabs/schedule/AppOverrideRow.jsx b/client/src/components/cos/tabs/schedule/AppOverrideRow.jsx index dd80aec733..ca25f45e2d 100644 --- a/client/src/components/cos/tabs/schedule/AppOverrideRow.jsx +++ b/client/src/components/cos/tabs/schedule/AppOverrideRow.jsx @@ -12,6 +12,14 @@ const AppOverrideRow = memo(function AppOverrideRow({ app, taskType, globalInter const [updating, setUpdating] = useState(false); const [cronEditing, setCronEditing] = useState(false); const isEnabled = override?.enabled === true; + // The row's toggle is the app's ON/OFF switch for this scheduled task, not a + // "use my overrides" flag — every other control on the row is inert until it + // is on. Say so in the accessible name and the tooltip, since the switch + // itself carries no visible state text. + const runToggleLabel = `Run ${taskType} for ${app.name}: ${isEnabled ? 'on' : 'off'}`; + const runToggleTitle = isEnabled + ? `${taskType} runs for ${app.name} on the schedule set here. Turn off to stop scheduling it for this app.` + : `${taskType} does not run for ${app.name}. Turn on to schedule it for this app.`; const currentInterval = override?.interval || null; const hasCron = isCronExpression(currentInterval); // Same effective-value rule the AGENT_OPTIONS buttons use: this app's override @@ -105,20 +113,25 @@ const AppOverrideRow = memo(function AppOverrideRow({ app, taskType, globalInter } ); + const runToggle = ( + <> + Run + + + ); + return (
{app.name} -
- -
+
{runToggle}
@@ -303,15 +316,7 @@ const AppOverrideRow = memo(function AppOverrideRow({ app, taskType, globalInter )} -
- -
+
{runToggle}
diff --git a/client/src/components/cos/tabs/schedule/AppOverrideRow.test.jsx b/client/src/components/cos/tabs/schedule/AppOverrideRow.test.jsx index 3957ec0783..4e36833f19 100644 --- a/client/src/components/cos/tabs/schedule/AppOverrideRow.test.jsx +++ b/client/src/components/cos/tabs/schedule/AppOverrideRow.test.jsx @@ -277,3 +277,31 @@ describe('AppOverrideRow — per-app provider pin', () => { expect(onUpdate).toHaveBeenCalledWith('app-1', 'ux', { providerId: 'opencode-llama-tui', model: null }); }); }); + +describe('AppOverrideRow — run toggle', () => { + // The switch is what turns the scheduled task on for the app; it is NOT an + // "apply my overrides" flag. It carries no visible on/off text, so the + // accessible name has to say which task, which app, and the current state. + // The row renders the switch twice (a mobile slot and a desktop one), so both + // are asserted rather than indexing into the list. + it('names the task, the app, and the current state on every slot', () => { + renderRow({ taskType: 'feature-ideas' }); + const off = screen.getAllByRole('switch', { name: 'Run feature-ideas for Acme: off' }); + expect(off).toHaveLength(2); + off.forEach(sw => expect(sw).toHaveAttribute('aria-checked', 'false')); + + cleanup(); + renderRow({ taskType: 'feature-ideas', override: { enabled: true } }); + const on = screen.getAllByRole('switch', { name: 'Run feature-ideas for Acme: on' }); + expect(on).toHaveLength(2); + on.forEach(sw => expect(sw).toHaveAttribute('aria-checked', 'true')); + }); + + it('enables the task for the app while preserving its interval override', async () => { + const onUpdate = renderRow({ override: { enabled: false, interval: 'on-demand' } }); + await act(async () => { + fireEvent.click(screen.getAllByRole('switch', { name: 'Run feature-ideas for Acme: off' })[0]); + }); + expect(onUpdate).toHaveBeenCalledWith('app-1', 'feature-ideas', { enabled: true, interval: 'on-demand' }); + }); +}); diff --git a/client/src/components/cos/tabs/schedule/AppTaskTypeSection.jsx b/client/src/components/cos/tabs/schedule/AppTaskTypeSection.jsx index fce71b6f9c..ec58e2b597 100644 --- a/client/src/components/cos/tabs/schedule/AppTaskTypeSection.jsx +++ b/client/src/components/cos/tabs/schedule/AppTaskTypeSection.jsx @@ -47,7 +47,7 @@ export default function AppTaskTypeSection({ tasks, apps, providers, providersLo

- Tasks that analyze and improve PortOS and managed apps. Click a card to configure schedule and per-app overrides. + Tasks that analyze and improve PortOS and managed apps. Click a card to configure its schedule and to turn it on or off per app.

diff --git a/client/src/components/cos/tabs/schedule/PerAppOverrideList.jsx b/client/src/components/cos/tabs/schedule/PerAppOverrideList.jsx index 35f1f73a60..d1df669284 100644 --- a/client/src/components/cos/tabs/schedule/PerAppOverrideList.jsx +++ b/client/src/components/cos/tabs/schedule/PerAppOverrideList.jsx @@ -26,12 +26,20 @@ export default function PerAppOverrideList({ taskType, config, apps, providers, return (
-
-

Per-App Overrides

+
+
+

Per-App Options

+

+ Each app's toggle turns {taskType} on or off for that app — + an app stays off until you switch it on here, whatever the rest of the row says. The other controls are + optional: leave one on Inherit and it follows the global defaults. +

+
diff --git a/client/src/components/cos/tabs/schedule/TaskConfigDrawer.jsx b/client/src/components/cos/tabs/schedule/TaskConfigDrawer.jsx index 80b7652c1f..9626905092 100644 --- a/client/src/components/cos/tabs/schedule/TaskConfigDrawer.jsx +++ b/client/src/components/cos/tabs/schedule/TaskConfigDrawer.jsx @@ -16,8 +16,9 @@ import PerAppOverrideList from './PerAppOverrideList'; // - Stage config — per-stage provider/model (only when the task has a // pipeline; count = number of stages) // - Global defaults — schedule/provider/prompt controls (always present) -// - Per-app overrides — per-app enablement (only when there are active apps; -// count = number of active apps) +// - Per-app options — turns the task on/off per app, plus that app's +// optional per-app settings (only when there are +// active apps; count = number of active apps) // The active tab lives in the `taskTab` URL param so it survives reload and is // shareable. TaskHeader (identity + badges) stays at the top of every tab. export default function TaskConfigDrawer({ @@ -45,11 +46,11 @@ export default function TaskConfigDrawer({ const hasOverrides = activeApps.length > 0; // Tabs are dynamic: a task without a pipeline hides Stage config, and an - // install with no active apps hides Per-app overrides — never an empty tab. + // install with no active apps hides Per-app options — never an empty tab. const tabs = [ hasStages && { id: 'stages', label: 'Stage config', count: stages.length }, { id: 'global', label: 'Global defaults' }, - hasOverrides && { id: 'overrides', label: 'Per-app overrides', count: activeApps.length }, + hasOverrides && { id: 'overrides', label: 'Per-app options', count: activeApps.length }, ].filter(Boolean); const tabIds = tabs.map(t => t.id); const defaultTab = hasStages ? 'stages' : 'global'; diff --git a/client/src/components/cos/tabs/schedule/TaskConfigDrawer.test.jsx b/client/src/components/cos/tabs/schedule/TaskConfigDrawer.test.jsx index 2c6f0f9621..a8a9e09608 100644 --- a/client/src/components/cos/tabs/schedule/TaskConfigDrawer.test.jsx +++ b/client/src/components/cos/tabs/schedule/TaskConfigDrawer.test.jsx @@ -65,10 +65,10 @@ describe('TaskConfigDrawer tabbed layout', () => { renderDrawer(); expect(screen.getByRole('tab', { name: /Stage config/ })).toBeInTheDocument(); expect(screen.getByRole('tab', { name: /Global defaults/ })).toBeInTheDocument(); - expect(screen.getByRole('tab', { name: /Per-app overrides/ })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /Per-app options/ })).toBeInTheDocument(); // Counts surface: 2 stages, 1 active (non-archived) app. expect(screen.getByRole('tab', { name: /Stage config/ })).toHaveTextContent('2'); - expect(screen.getByRole('tab', { name: /Per-app overrides/ })).toHaveTextContent('1'); + expect(screen.getByRole('tab', { name: /Per-app options/ })).toHaveTextContent('1'); }); it('opens on Stage config and mounts only the active tab section', () => { @@ -78,13 +78,13 @@ describe('TaskConfigDrawer tabbed layout', () => { expect(screen.queryByTestId('override-list')).not.toBeInTheDocument(); }); - it('switches to Global defaults and Per-app overrides on tab click', () => { + it('switches to Global defaults and Per-app options on tab click', () => { renderDrawer(); fireEvent.click(screen.getByRole('tab', { name: /Global defaults/ })); expect(screen.getByTestId('global-config')).toHaveTextContent('global:do:next'); expect(screen.queryByTestId('stage-config')).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole('tab', { name: /Per-app overrides/ })); + fireEvent.click(screen.getByRole('tab', { name: /Per-app options/ })); expect(screen.getByTestId('override-list')).toHaveTextContent('overrides:do:next'); expect(screen.queryByTestId('global-config')).not.toBeInTheDocument(); }); @@ -106,9 +106,9 @@ describe('TaskConfigDrawer tabbed layout', () => { expect(screen.getByTestId('global-config')).toBeInTheDocument(); }); - it('hides the Per-app overrides tab when there are no active apps', () => { + it('hides the Per-app options tab when there are no active apps', () => { renderDrawer({ apps: [{ id: 'a', name: 'A', archived: true }] }); - expect(screen.queryByRole('tab', { name: /Per-app overrides/ })).not.toBeInTheDocument(); + expect(screen.queryByRole('tab', { name: /Per-app options/ })).not.toBeInTheDocument(); }); it('renders nothing when config is not yet loaded', () => { From df8203084c838e710bd3e2a179e9259822621602 Mon Sep 17 00:00:00 2001 From: Adam Eivy Date: Thu, 3 Sep 2026 20:23:04 +0000 Subject: [PATCH 2/2] fix(cos): label the per-app toggle Enabled, not Run The app-side Automation row already has a Run button that triggers the task now, so labelling the on/off switch beside it Run put two controls with opposite meanings under one word. Enabled names the state the switch actually holds, and the tooltip still spells out what turning it on or off does to the schedule. Claude-Session: https://claude.ai/code/session_01VNAXigngmBpiD2HJzMWRiz --- client/src/components/apps/tabs/AutomationTab.jsx | 7 +++++-- .../cos/tabs/schedule/AppOverrideRow.jsx | 14 +++++++------- .../cos/tabs/schedule/AppOverrideRow.test.jsx | 8 ++++---- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/client/src/components/apps/tabs/AutomationTab.jsx b/client/src/components/apps/tabs/AutomationTab.jsx index 454ff132ff..ac819a2066 100644 --- a/client/src/components/apps/tabs/AutomationTab.jsx +++ b/client/src/components/apps/tabs/AutomationTab.jsx @@ -256,19 +256,22 @@ export default function AutomationTab({ appId, appName }) {
{/* Row 1: name + toggle + configure + run now */}
+ {/* Labelled "Enabled", not "Run" — the row already has a Run + (trigger now) button, and this switch is the on/off state + that gates both the schedule and that button. */} - Run + Enabled handleToggle(taskType, isEnabled)} size="sm" activeColor="bg-port-success" - ariaLabel={`Run ${taskType} for this app: ${isEnabled ? 'on' : 'off'}`} + ariaLabel={`${taskType} enabled for this app: ${isEnabled ? 'on' : 'off'}`} />
diff --git a/client/src/components/cos/tabs/schedule/AppOverrideRow.jsx b/client/src/components/cos/tabs/schedule/AppOverrideRow.jsx index ca25f45e2d..9963c95919 100644 --- a/client/src/components/cos/tabs/schedule/AppOverrideRow.jsx +++ b/client/src/components/cos/tabs/schedule/AppOverrideRow.jsx @@ -16,8 +16,8 @@ const AppOverrideRow = memo(function AppOverrideRow({ app, taskType, globalInter // "use my overrides" flag — every other control on the row is inert until it // is on. Say so in the accessible name and the tooltip, since the switch // itself carries no visible state text. - const runToggleLabel = `Run ${taskType} for ${app.name}: ${isEnabled ? 'on' : 'off'}`; - const runToggleTitle = isEnabled + const enabledToggleLabel = `${taskType} enabled for ${app.name}: ${isEnabled ? 'on' : 'off'}`; + const enabledToggleTitle = isEnabled ? `${taskType} runs for ${app.name} on the schedule set here. Turn off to stop scheduling it for this app.` : `${taskType} does not run for ${app.name}. Turn on to schedule it for this app.`; const currentInterval = override?.interval || null; @@ -113,15 +113,15 @@ const AppOverrideRow = memo(function AppOverrideRow({ app, taskType, globalInter } ); - const runToggle = ( + const enabledToggle = ( <> - Run + Enabled ); @@ -131,7 +131,7 @@ const AppOverrideRow = memo(function AppOverrideRow({ app, taskType, globalInter
{app.name} -
{runToggle}
+
{enabledToggle}
@@ -316,7 +316,7 @@ const AppOverrideRow = memo(function AppOverrideRow({ app, taskType, globalInter )} -
{runToggle}
+
{enabledToggle}
diff --git a/client/src/components/cos/tabs/schedule/AppOverrideRow.test.jsx b/client/src/components/cos/tabs/schedule/AppOverrideRow.test.jsx index 4e36833f19..ac319df612 100644 --- a/client/src/components/cos/tabs/schedule/AppOverrideRow.test.jsx +++ b/client/src/components/cos/tabs/schedule/AppOverrideRow.test.jsx @@ -278,7 +278,7 @@ describe('AppOverrideRow — per-app provider pin', () => { }); }); -describe('AppOverrideRow — run toggle', () => { +describe('AppOverrideRow — enabled toggle', () => { // The switch is what turns the scheduled task on for the app; it is NOT an // "apply my overrides" flag. It carries no visible on/off text, so the // accessible name has to say which task, which app, and the current state. @@ -286,13 +286,13 @@ describe('AppOverrideRow — run toggle', () => { // are asserted rather than indexing into the list. it('names the task, the app, and the current state on every slot', () => { renderRow({ taskType: 'feature-ideas' }); - const off = screen.getAllByRole('switch', { name: 'Run feature-ideas for Acme: off' }); + const off = screen.getAllByRole('switch', { name: 'feature-ideas enabled for Acme: off' }); expect(off).toHaveLength(2); off.forEach(sw => expect(sw).toHaveAttribute('aria-checked', 'false')); cleanup(); renderRow({ taskType: 'feature-ideas', override: { enabled: true } }); - const on = screen.getAllByRole('switch', { name: 'Run feature-ideas for Acme: on' }); + const on = screen.getAllByRole('switch', { name: 'feature-ideas enabled for Acme: on' }); expect(on).toHaveLength(2); on.forEach(sw => expect(sw).toHaveAttribute('aria-checked', 'true')); }); @@ -300,7 +300,7 @@ describe('AppOverrideRow — run toggle', () => { it('enables the task for the app while preserving its interval override', async () => { const onUpdate = renderRow({ override: { enabled: false, interval: 'on-demand' } }); await act(async () => { - fireEvent.click(screen.getAllByRole('switch', { name: 'Run feature-ideas for Acme: off' })[0]); + fireEvent.click(screen.getAllByRole('switch', { name: 'feature-ideas enabled for Acme: off' })[0]); }); expect(onUpdate).toHaveBeenCalledWith('app-1', 'feature-ideas', { enabled: true, interval: 'on-demand' }); });