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
71 changes: 56 additions & 15 deletions apps/web/src/app/(app)/claw/components/SettingsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,7 @@ function MorningBriefingCard({
fallbackReadiness,
isRunning,
actionsReady,
onRequestUpgrade,
}: {
mutations: ClawMutations;
briefingStatus: MorningBriefingStatusLite | undefined;
Expand All @@ -511,6 +512,8 @@ function MorningBriefingCard({
};
isRunning: boolean;
actionsReady: boolean;
/** Callback that opens the focused upgrade confirmation flow. */
onRequestUpgrade?: () => void;
}) {
const [requestedDay, setRequestedDay] = useState<'today' | 'yesterday' | null>(null);
const { data: readData, isFetching: isReading } = useClawReadMorningBriefing(requestedDay, true);
Expand Down Expand Up @@ -539,12 +542,17 @@ function MorningBriefingCard({
} as const);

const hasSchedule = Boolean(briefingStatus?.cron && briefingStatus?.timezone);
const { desiredEnabled, observedEnabled, hasResolvedBriefingToggleState, isWarmupState } =
deriveMorningBriefingCardState({
isRunning,
actionsReady,
briefingStatus,
});
const {
desiredEnabled,
observedEnabled,
hasResolvedBriefingToggleState,
isWarmupState,
isControllerOutOfDate,
} = deriveMorningBriefingCardState({
isRunning,
actionsReady,
briefingStatus,
});
const reconcileState = briefingStatus?.reconcileState ?? 'idle';
const lastReconcileAction = briefingStatus?.lastReconcileAction ?? null;
const isTransitioning =
Expand All @@ -553,6 +561,10 @@ function MorningBriefingCard({
mutations.disableMorningBriefing.isPending;

const statusLabel = (() => {
if (isControllerOutOfDate) {
return 'Upgrade Required';
}

if (isWarmupState) {
return 'Instance Warming Up';
}
Expand Down Expand Up @@ -585,13 +597,14 @@ function MorningBriefingCard({

return observedEnabled ? 'Enabled' : 'Disabled';
})();
const statusVariant = isWarmupState
? 'secondary'
: statusLabel === 'Instance Stopped'
const statusVariant =
isControllerOutOfDate || isWarmupState
? 'secondary'
: observedEnabled || (isTransitioning && desiredEnabled)
? 'default'
: 'secondary';
: statusLabel === 'Instance Stopped'
? 'secondary'
: observedEnabled || (isTransitioning && desiredEnabled)
? 'default'
: 'secondary';

const readySources = [
sourceReadiness.github.configured ? 'GitHub' : null,
Expand All @@ -610,12 +623,17 @@ function MorningBriefingCard({
: readySources.length === 0
? 'No sources are connected yet. Configure GitHub, Linear, or Web Search to generate richer briefings.'
: `Connected sources: ${joinFriendlyList(readySources)}. Disconnected sources: ${joinFriendlyList(missingSources)}.`;
const showScheduleDetails = !isWarmupState && hasSchedule && desiredEnabled;
const controlsEnabled = actionsReady && !isWarmupState;
const showScheduleDetails =
!isWarmupState && !isControllerOutOfDate && hasSchedule && desiredEnabled;
const controlsEnabled = actionsReady && !isWarmupState && !isControllerOutOfDate;
const canUseBriefingControls = controlsEnabled && desiredEnabled;
const lastDelivery = briefingStatus?.lastDelivery ?? [];
const showLastDelivery =
!isWarmupState && actionsReady && hasResolvedBriefingToggleState && lastDelivery.length > 0;
!isWarmupState &&
!isControllerOutOfDate &&
actionsReady &&
hasResolvedBriefingToggleState &&
lastDelivery.length > 0;
const deliveryChannelLabel = {
telegram: 'Telegram',
discord: 'Discord',
Expand All @@ -635,6 +653,28 @@ function MorningBriefingCard({

return (
<div className="rounded-lg border px-4 py-3">
{isControllerOutOfDate && (
<div className="mb-3 flex items-start gap-3 rounded-md border border-amber-500/30 bg-amber-500/10 p-3">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-400" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-amber-200">Upgrade required</p>
<p className="text-muted-foreground text-xs">
Morning Briefing requires a newer KiloClaw version. Upgrade to enable scheduling and
delivery.
</p>
</div>
{onRequestUpgrade && (
<Button
size="sm"
variant="outline"
className="border-amber-500/30 text-amber-400 hover:bg-amber-500/20 hover:text-amber-300"
onClick={onRequestUpgrade}
>
Upgrade
</Button>
)}
</div>
)}
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-start gap-3">
<Newspaper className="text-muted-foreground h-5 w-5 shrink-0" />
Expand Down Expand Up @@ -1925,6 +1965,7 @@ export function SettingsTab({
briefingStatus={morningBriefingStatus}
isRunning={isRunning}
actionsReady={morningBriefingActionsReady}
onRequestUpgrade={onRequestUpgrade}
fallbackReadiness={{
githubConfigured: configuredSecrets.github ?? false,
linearConfigured: configuredSecrets.linear ?? false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,23 @@ describe('deriveMorningBriefingCardState', () => {
expect(state.desiredEnabled).toBe(true);
expect(state.observedEnabled).toBe(true);
});

test('flags controller_route_unavailable and suppresses warmup state', () => {
const state = deriveMorningBriefingCardState({
isRunning: true,
actionsReady: true,
briefingStatus: {
code: 'controller_route_unavailable',
enabled: false,
desiredEnabled: false,
observedEnabled: false,
reconcileState: 'idle',
},
});

expect(state.isControllerOutOfDate).toBe(true);
// Warmup must be suppressed so the upgrade banner is the only signal —
// otherwise an out-of-date controller would also render as "warming up".
expect(state.isWarmupState).toBe(false);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export type MorningBriefingCardState = {
hasResolvedBriefingToggleState: boolean;
isGatewayWarmupStatus: boolean;
isWarmupState: boolean;
isControllerOutOfDate: boolean;
};

export function deriveMorningBriefingCardState(
Expand All @@ -21,11 +22,15 @@ export function deriveMorningBriefingCardState(
const observedEnabledValue =
input.briefingStatus?.observedEnabled ?? input.briefingStatus?.enabled;
const isGatewayWarmupStatus = input.briefingStatus?.code === 'gateway_warming_up';
const isControllerOutOfDate = input.briefingStatus?.code === 'controller_route_unavailable';
const hasResolvedBriefingToggleState =
typeof desiredEnabledValue === 'boolean' && typeof observedEnabledValue === 'boolean';
const desiredEnabled = desiredEnabledValue ?? false;
const observedEnabled = observedEnabledValue ?? false;
// Out-of-date controller takes precedence over warmup: the user needs to act
// (upgrade), not wait. Suppress warmup so the two banners don't fight.
const isWarmupState =
!isControllerOutOfDate &&
input.isRunning &&
(input.actionsReady === false || isGatewayWarmupStatus || !hasResolvedBriefingToggleState);

Expand All @@ -35,5 +40,6 @@ export function deriveMorningBriefingCardState(
hasResolvedBriefingToggleState,
isGatewayWarmupStatus,
isWarmupState,
isControllerOutOfDate,
};
}
21 changes: 21 additions & 0 deletions services/kiloclaw/src/routes/platform-morning-briefing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,27 @@ describe('platform morning-briefing warm-up handling', () => {
});
});

it('returns graceful unavailable payload at 200 when controller predates the status route', async () => {
// The DO returns null when the controller route is missing (controller
// predates the morning-briefing route). The dashboard polls this endpoint
// every 30s, so 404 here would generate continuous user-facing errors.
const getMorningBriefingStatus = vi.fn<() => Promise<unknown>>().mockResolvedValue(null);
const env = baseEnv({ getMorningBriefingStatus });

const response = await platform.request('/morning-briefing/status?userId=user-1', {}, env);

expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({
ok: false,
enabled: false,
desiredEnabled: false,
observedEnabled: false,
reconcileState: 'idle',
code: 'controller_route_unavailable',
error: 'Morning Briefing unavailable (controller too old)',
});
});

it('does not treat 401 as warm-up for enable retries', async () => {
const enableMorningBriefing = vi
.fn<() => Promise<{ ok: boolean; enabled: boolean }>>()
Expand Down
19 changes: 15 additions & 4 deletions services/kiloclaw/src/routes/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1993,10 +1993,21 @@ platform.get('/morning-briefing/status', async c => {
'getMorningBriefingStatus'
);
if (!result) {
return jsonError(
'Morning Briefing unavailable (controller too old)',
404,
'controller_route_unavailable'
// Controller predates this route. The dashboard polls status every 30s,
// so a 404 here would generate continuous user-facing errors. Return a
// typed "unavailable" payload at 200 instead — same shape pattern as
// the gateway_warming_up branch below.
return c.json(
{
ok: false,
enabled: false,
desiredEnabled: false,
observedEnabled: false,
reconcileState: 'idle',
code: 'controller_route_unavailable',
error: 'Morning Briefing unavailable (controller too old)',
},
200
);
}
return c.json(result, 200);
Expand Down