diff --git a/client/src/components/apps/tabs/AutomationTab.jsx b/client/src/components/apps/tabs/AutomationTab.jsx index 82d73be830..ac819a2066 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..9963c95919 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 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; 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 enabledToggle = ( + <> + Enabled + + + ); + return (
{app.name} -
- -
+
{enabledToggle}
@@ -303,15 +316,7 @@ const AppOverrideRow = memo(function AppOverrideRow({ app, taskType, globalInter )} -
- -
+
{enabledToggle}
diff --git a/client/src/components/cos/tabs/schedule/AppOverrideRow.test.jsx b/client/src/components/cos/tabs/schedule/AppOverrideRow.test.jsx index 3957ec0783..ac319df612 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 — 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. + // 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: '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: 'feature-ideas enabled 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: 'feature-ideas enabled 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', () => {