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
26 changes: 20 additions & 6 deletions client/src/components/cos/tabs/ScheduleTab.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useSearchParams } from 'react-router';
import { AlertCircle, RefreshCw } from 'lucide-react';
import toast from '../../ui/Toast';
import * as api from '../../../services/api';
import { formatDateTime, formatTimeOfDaySeconds } from '../../../utils/formatters';
import { formatDateTime, formatTimeOfDaySeconds, timeAgo } from '../../../utils/formatters';
import Banner from '../../ui/Banner';
import { CodeReviewDefaultsProvider } from '../../../hooks/useCodeReviewDefaults';
import { useAppOverrideActions } from '../../../hooks/useAppOverrideActions';
Expand Down Expand Up @@ -40,7 +40,7 @@ function mergeOnDemandRequest(schedule, request) {
// passed down — same convention as TasksTab/AgentsTab — so this tab's provider/
// model pickers stay live without standing up a second independent poll of the
// same data.
export default function ScheduleTab({ apps, providers, providersLoaded, activeProviderId }) {
export default function ScheduleTab({ apps, providers, providersLoaded, activeProviderId, daemonRunning }) {
const [searchParams, setSearchParams] = useSearchParams();
const [schedule, setSchedule] = useState(null);
const [loading, setLoading] = useState(true);
Expand Down Expand Up @@ -101,7 +101,12 @@ export default function ScheduleTab({ apps, providers, providersLoaded, activePr
if (!result?.success) return null;

const appName = appId ? apps?.find(app => app.id === appId)?.name || 'selected app' : null;
toast.success(`Queued ${taskType} request${appName ? ` for ${appName}` : ''} — it will appear in Tasks when evaluation begins`);
const queuedMsg = `Queued ${taskType} request${appName ? ` for ${appName}` : ''}`;
if (daemonRunning === false) {
toast.error(`${queuedMsg} — but the CoS daemon is stopped, so it will not run until you start it`);
} else {
toast.success(`${queuedMsg} — it will appear in Tasks when evaluation begins`);
}

// The POST returns the persisted request. Paint it immediately instead of
// waiting for a second round trip; the evaluator may drain it into Tasks
Expand All @@ -112,7 +117,7 @@ export default function ScheduleTab({ apps, providers, providersLoaded, activePr
}
fetchSchedule();
return result.request || true;
}, [apps, fetchSchedule]);
}, [apps, fetchSchedule, daemonRunning]);

const handleTriggerAppImprovement = handleTriggerTask;

Expand Down Expand Up @@ -170,11 +175,20 @@ export default function ScheduleTab({ apps, providers, providersLoaded, activePr
)}

{schedule.onDemandRequests?.length > 0 && (
<Banner tone="info" size="lg" title="Pending On-Demand Tasks">
<Banner
tone={daemonRunning === false ? 'warning' : 'info'}
size="lg"
title="Pending On-Demand Tasks"
>
{daemonRunning === false && (
<div className="text-sm mb-2">
The CoS daemon is stopped, so these requests will not run until it's started — use the Start button above.
</div>
)}
<div className="space-y-1 mt-2">
{schedule.onDemandRequests.map(req => (
<div key={req.id} className="text-sm text-gray-300">
{req.taskType}{req.appId ? ` (${apps?.find(app => app.id === req.appId)?.name || req.appId})` : ''} - requested {formatTimeOfDaySeconds(req.requestedAt)}
{req.taskType}{req.appId ? ` (${apps?.find(app => app.id === req.appId)?.name || req.appId})` : ''} - requested {formatTimeOfDaySeconds(req.requestedAt)} ({timeAgo(req.requestedAt)})
</div>
))}
</div>
Expand Down
54 changes: 54 additions & 0 deletions client/src/components/cos/tabs/ScheduleTab.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,4 +98,58 @@ describe('ScheduleTab on-demand feedback', () => {
expect(screen.getByText('Pending On-Demand Tasks')).toBeVisible();
expect(screen.getByText(/review \(Example App\) - requested/)).toBeVisible();
});

it('warns instead of celebrating a queued request when the CoS daemon is stopped', async () => {
const user = userEvent.setup();
const request = {
id: 'request-2',
taskType: 'review',
appId: 'app-1',
requestedAt: '2026-09-01T12:00:00.000Z',
};
api.getCodeReviewDefaults.mockResolvedValue({});
api.getCosSchedule
.mockResolvedValueOnce({
improvementEnabled: true,
tasks: {
review: {
type: 'on-demand',
enabled: true,
enabledAppCount: 1,
totalAppCount: 1,
invocation: { userInvokable: true },
},
},
onDemandRequests: [],
})
// Hold the background refresh so this assertion proves the
// optimistically-painted request (and its daemon-stopped warning)
// stays visible without waiting on a second round trip.
.mockReturnValueOnce(new Promise(() => {}));
api.triggerCosOnDemandTask.mockResolvedValue({ success: true, request });

render(
<MemoryRouter>
<ScheduleTab
apps={[{ id: 'app-1', name: 'Example App' }]}
providers={[]}
activeProviderId={null}
daemonRunning={false}
/>
</MemoryRouter>,
);

await user.click(await screen.findByRole('button', { name: /Run on App/i }));
await user.click(screen.getByRole('button', { name: 'Example App' }));

await waitFor(() => expect(api.triggerCosOnDemandTask).toHaveBeenCalledWith(
'review',
'app-1',
{ silent: true },
));
expect(toast.error).toHaveBeenCalledWith(
'Queued review request for Example App — but the CoS daemon is stopped, so it will not run until you start it',
);
expect(await screen.findByText(/CoS daemon is stopped/)).toBeVisible();
});
});
2 changes: 1 addition & 1 deletion client/src/pages/ChiefOfStaff.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -1237,7 +1237,7 @@ export default function ChiefOfStaff() {
{activeTab === 'schedule' && (
<div role="tabpanel" id="tabpanel-schedule" aria-labelledby="tab-schedule">
<Suspense fallback={<TabLoadFallback label="schedule" />}>
<ScheduleTab apps={apps} providers={providers} activeProviderId={activeProviderId} providersLoaded={providersLoaded} />
<ScheduleTab apps={apps} providers={providers} activeProviderId={activeProviderId} providersLoaded={providersLoaded} daemonRunning={status?.running} />
</Suspense>
</div>
)}
Expand Down