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
30 changes: 25 additions & 5 deletions client/src/components/apps/tabs/AutomationTab.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -209,10 +209,13 @@ export default function AutomationTab({ appId, appName }) {
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div>
<h3 className="text-lg font-semibold text-white">Task Type Overrides</h3>
<p className="text-sm text-gray-500">Per-app automation preferences for CoS task scheduling</p>
<h3 className="text-lg font-semibold text-white">Scheduled Task Options</h3>
<p className="text-sm text-gray-500">
Each toggle turns that CoS scheduled task on or off for this app. The controls beside it are optional —
leave one on <em>Inherit</em> and it follows the global schedule defaults.
</p>
</div>
<ToggleSwitch enabled={allEnabled} onChange={handleToggleAll} size="sm" activeColor="bg-port-success" ariaLabel={allEnabled ? 'Disable all automations' : 'Enable all automations'} />
<ToggleSwitch enabled={allEnabled} onChange={handleToggleAll} size="sm" activeColor="bg-port-success" ariaLabel={allEnabled ? 'Disable every scheduled task for this app' : 'Enable every scheduled task for this app'} />
</div>
<button
onClick={fetchData}
Expand Down Expand Up @@ -253,15 +256,32 @@ export default function AutomationTab({ appId, appName }) {
<div key={taskType} className="bg-port-card border border-port-border rounded-lg p-3 space-y-2">
{/* Row 1: name + toggle + configure + run now */}
<div className="flex items-center gap-3">
<ToggleSwitch enabled={isEnabled} onChange={() => handleToggle(taskType, isEnabled)} size="sm" activeColor="bg-port-success" />
{/* 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. */}
<span
className="flex items-center gap-1.5 shrink-0"
title={isEnabled
? `${taskType} runs for this app on the schedule below. Turn off to stop scheduling it.`
: `${taskType} does not run for this app. Turn on to schedule it.`}
>
<span className="text-[10px] uppercase tracking-wide text-gray-500">Enabled</span>
<ToggleSwitch
enabled={isEnabled}
onChange={() => handleToggle(taskType, isEnabled)}
size="sm"
activeColor="bg-port-success"
ariaLabel={`${taskType} enabled for this app: ${isEnabled ? 'on' : 'off'}`}
/>
</span>
<div className="flex-1 min-w-0">
<span className="text-white font-mono text-xs">{taskType}</span>
<div className="text-xs text-gray-500">{effectiveLabel}{intervalSuffix}</div>
</div>
<button
onClick={() => setExpandedTaskType(prev => prev === taskType ? null : taskType)}
aria-expanded={isExpanded}
aria-label={`${isExpanded ? 'Hide' : 'Show'} provider and model overrides for ${taskType}`}
aria-label={`${isExpanded ? 'Hide' : 'Show'} provider and model options for ${taskType}`}
className="px-2 py-1 bg-port-border/60 text-gray-300 hover:bg-port-border rounded text-xs inline-flex items-center gap-1 shrink-0"
>
{isExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
Expand Down
16 changes: 8 additions & 8 deletions client/src/components/apps/tabs/AutomationTab.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,11 @@ beforeEach(() => {
vi.clearAllMocks();
});

describe('AutomationTab per-app overrides', () => {
describe('AutomationTab per-app options', () => {
it('Configure toggle expands the provider override panel', async () => {
await renderTab();
const row = rowFor('layered-intelligence');
const configureBtn = within(row).getByRole('button', { name: /show provider and model overrides/i });
const configureBtn = within(row).getByRole('button', { name: /show provider and model options/i });
expect(configureBtn).toHaveAttribute('aria-expanded', 'false');
// Provider selector is not rendered until expanded.
expect(within(row).queryByLabelText('Provider override')).toBeNull();
Expand All @@ -92,7 +92,7 @@ describe('AutomationTab per-app overrides', () => {
it('changing the provider PATCHes updateAppTaskTypeOverride with providerId + cleared model', async () => {
await renderTab();
const row = rowFor('layered-intelligence');
fireEvent.click(within(row).getByRole('button', { name: /show provider and model overrides/i }));
fireEvent.click(within(row).getByRole('button', { name: /show provider and model options/i }));

const providerSelect = within(row).getByLabelText('Provider override');
fireEvent.change(providerSelect, { target: { value: 'claude-cli' } });
Expand All @@ -109,7 +109,7 @@ describe('AutomationTab per-app overrides', () => {
it('changing the model PATCHes updateAppTaskTypeOverride with the model', async () => {
await renderTab({ 'layered-intelligence': { providerId: 'claude-cli' } });
const row = rowFor('layered-intelligence');
fireEvent.click(within(row).getByRole('button', { name: /show provider and model overrides/i }));
fireEvent.click(within(row).getByRole('button', { name: /show provider and model options/i }));

fireEvent.change(within(row).getByLabelText('Model'), { target: { value: 'sonnet' } });

Expand All @@ -124,7 +124,7 @@ describe('AutomationTab per-app overrides', () => {
it('excludes disabled providers from the picker', async () => {
await renderTab();
const row = rowFor('layered-intelligence');
fireEvent.click(within(row).getByRole('button', { name: /show provider and model overrides/i }));
fireEvent.click(within(row).getByRole('button', { name: /show provider and model options/i }));
const providerSelect = within(row).getByLabelText('Provider override');
expect(within(providerSelect).queryByText('Disabled')).toBeNull();
expect(within(providerSelect).getByText('Claude Code')).toBeInTheDocument();
Expand All @@ -133,7 +133,7 @@ describe('AutomationTab per-app overrides', () => {
it('layered-intelligence row shows a behavior link that deep-links to the Intelligence tab', async () => {
await renderTab();
const row = rowFor('layered-intelligence');
fireEvent.click(within(row).getByRole('button', { name: /show provider and model overrides/i }));
fireEvent.click(within(row).getByRole('button', { name: /show provider and model options/i }));

const link = within(row).getByRole('button', { name: /configure behavior/i });
fireEvent.click(link);
Expand All @@ -145,14 +145,14 @@ describe('AutomationTab per-app overrides', () => {
it('offers the same provider picker on a task type with no hook', async () => {
await renderTab();
const row = rowFor('app-improvement');
fireEvent.click(within(row).getByRole('button', { name: /show provider and model overrides/i }));
fireEvent.click(within(row).getByRole('button', { name: /show provider and model options/i }));
expect(within(row).getByLabelText('Provider override')).toBeInTheDocument();
});

it('clearing the provider sends explicit nulls, matching the other pin surfaces', async () => {
await renderTab({ 'app-improvement': { providerId: 'claude-cli', model: 'opus' } });
const row = rowFor('app-improvement');
fireEvent.click(within(row).getByRole('button', { name: /show provider and model overrides/i }));
fireEvent.click(within(row).getByRole('button', { name: /show provider and model options/i }));

fireEvent.change(within(row).getByLabelText('Provider override'), { target: { value: '' } });
await waitFor(() => expect(api.updateAppTaskTypeOverride).toHaveBeenCalledWith(
Expand Down
12 changes: 6 additions & 6 deletions client/src/components/cos/tabs/WorkflowTab.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ function TimelineRow({ node, occurrences, windows, timeline, hours, timezone, se
const divisions = hours === 168 ? 7 : 8;
const dependencyWarning = node.pendingDeps?.length > 0;

// App overrides only apply to task types (system jobs are not per-app). The
// Per-app options only apply to task types (system jobs are not per-app). The
// server's active-app counts drive the toggle + badge (single source of
// truth); PerAppOverrideList does its own `apps` filtering when expanded.
const { enabledAppCount = 0, totalAppCount = 0 } = node;
Expand All @@ -148,7 +148,7 @@ function TimelineRow({ node, occurrences, windows, timeline, hours, timezone, se
type="button"
onClick={() => onToggleExpand(node.id)}
aria-expanded={expanded}
aria-label={`${expanded ? 'Hide' : 'Show'} app overrides for ${node.label}`}
aria-label={`${expanded ? 'Hide' : 'Show'} per-app options for ${node.label}`}
title={countTitle}
className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-gray-500 hover:bg-white/10 hover:text-gray-300"
>
Expand Down Expand Up @@ -271,7 +271,7 @@ export default function WorkflowTab({ apps, providers }) {
const [graph, setGraph] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Which task rows have their per-app override panel expanded. Kept as local
// Which task rows have their per-app options panel expanded. Kept as local
// view state (a lightweight detail, not a selected record) rather than in the
// URL — the ?track= param already owns the selected editor panel.
const [expandedIds, setExpandedIds] = useState(() => new Set());
Expand Down Expand Up @@ -305,7 +305,7 @@ export default function WorkflowTab({ apps, providers }) {
return () => { fetchGeneration.current += 1; };
}, [fetchGraph]);

// Per-app override mutations are shared with ScheduleTab; refetch the graph so
// Per-app option mutations are shared with ScheduleTab; refetch the graph so
// the enabled-app counts and inherited defaults stay in sync after each change.
const { handleUpdateOverride, handleBulkToggleOverride } = useAppOverrideActions(apps, fetchGraph);

Expand Down Expand Up @@ -445,7 +445,7 @@ export default function WorkflowTab({ apps, providers }) {
type="button"
onClick={() => toggleExpand(node.id)}
aria-expanded={expandedIds.has(node.id)}
aria-label={`${expandedIds.has(node.id) ? 'Hide' : 'Show'} app overrides for ${node.label}`}
aria-label={`${expandedIds.has(node.id) ? 'Hide' : 'Show'} per-app options for ${node.label}`}
title={`${node.enabledAppCount || 0} of ${node.totalAppCount} apps enabled`}
className="flex h-full items-center border-l border-port-border/60 px-1.5 text-gray-500 hover:bg-white/10 hover:text-gray-300"
>
Expand All @@ -458,7 +458,7 @@ export default function WorkflowTab({ apps, providers }) {
</div>
{model.flexible.filter(node => expandedIds.has(node.id) && node.kind === 'task' && (node.totalAppCount || 0) > 0).map(node => (
<div key={node.id} className="mt-2 rounded border border-port-border/60 bg-port-bg/20 px-3 py-3">
<div className="mb-2 text-xs font-medium text-gray-300">{node.label} <span className="text-gray-600">· app overrides</span></div>
<div className="mb-2 text-xs font-medium text-gray-300">{node.label} <span className="text-gray-600">· per-app options</span></div>
<AppOverridePanel node={node} apps={apps} providers={providers} onUpdateOverride={handleUpdateOverride} onBulkToggleOverride={handleBulkToggleOverride} />
</div>
))}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ const renderTab = async (providers) => {
await act(async () => {
render(<MemoryRouter><WorkflowTab apps={APPS} providers={providers} /></MemoryRouter>);
});
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);
};

Expand Down
41 changes: 23 additions & 18 deletions client/src/components/cos/tabs/schedule/AppOverrideRow.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -105,20 +113,25 @@ const AppOverrideRow = memo(function AppOverrideRow({ app, taskType, globalInter
}
);

const enabledToggle = (
<>
<span className="text-[10px] uppercase tracking-wide text-gray-500">Enabled</span>
<ToggleSwitch
enabled={isEnabled}
onChange={handleToggle}
disabled={updating}
size="sm"
ariaLabel={enabledToggleLabel}
/>
</>
);

return (
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center gap-2 sm:gap-3 py-2 px-3 rounded hover:bg-port-card/30">
<div className="flex items-center gap-2 min-w-0 w-full sm:w-auto sm:flex-1">
<AppIcon icon={app.icon || 'package'} appId={app.id} hasAppIcon={!!app.appIconPath} size={16} className="text-gray-400 shrink-0" />
<span className="text-sm text-white truncate flex-1">{app.name}</span>
<div className="sm:hidden">
<ToggleSwitch
enabled={isEnabled}
onChange={handleToggle}
disabled={updating}
size="sm"
ariaLabel={`${isEnabled ? 'Disable' : 'Enable'} ${taskType} for ${app.name}`}
/>
</div>
<div className="sm:hidden flex items-center gap-1.5 shrink-0" title={enabledToggleTitle}>{enabledToggle}</div>
</div>

<div className="flex items-center gap-2 flex-wrap">
Expand Down Expand Up @@ -303,15 +316,7 @@ const AppOverrideRow = memo(function AppOverrideRow({ app, taskType, globalInter
</select>
)}

<div className="hidden sm:block">
<ToggleSwitch
enabled={isEnabled}
onChange={handleToggle}
disabled={updating}
size="sm"
ariaLabel={`${isEnabled ? 'Disable' : 'Enable'} ${taskType} for ${app.name}`}
/>
</div>
<div className="hidden sm:flex items-center gap-1.5" title={enabledToggleTitle}>{enabledToggle}</div>

</div>
</div>
Expand Down
28 changes: 28 additions & 0 deletions client/src/components/cos/tabs/schedule/AppOverrideRow.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export default function AppTaskTypeSection({ tasks, apps, providers, providersLo
</div>
</div>
<p className="text-sm text-gray-400">
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.
</p>

<div className="relative max-w-sm">
Expand Down
16 changes: 12 additions & 4 deletions client/src/components/cos/tabs/schedule/PerAppOverrideList.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,20 @@ export default function PerAppOverrideList({ taskType, config, apps, providers,

return (
<div>
<div className="flex items-center justify-between mb-2">
<h4 className="text-sm font-medium text-gray-400">Per-App Overrides</h4>
<div className="flex items-start justify-between gap-3 mb-2">
<div className="min-w-0">
<h4 className="text-sm font-medium text-gray-400">Per-App Options</h4>
<p className="text-xs text-gray-500 mt-0.5">
Each app&apos;s toggle turns <span className="font-mono text-gray-400">{taskType}</span> 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 <em>Inherit</em> and it follows the global defaults.
</p>
</div>
<button
onClick={handleBulkToggle}
disabled={bulkUpdating}
className={`text-xs px-2 py-1 rounded transition-colors ${
title={`${allEnabled ? 'Stop' : 'Start'} running ${taskType} for every active app`}
className={`text-xs px-2 py-1 rounded transition-colors shrink-0 ${
bulkUpdating ? 'opacity-50 cursor-not-allowed' : ''
} ${
allEnabled
Expand All @@ -41,7 +49,7 @@ export default function PerAppOverrideList({ taskType, config, apps, providers,
: 'text-port-accent hover:bg-port-accent/10'
}`}
>
{allEnabled ? 'Disable All' : 'Enable All'}
{allEnabled ? 'Disable for all apps' : 'Enable for all apps'}
</button>
</div>
<div className="border border-port-border rounded-lg divide-y divide-port-border/50">
Expand Down
Loading