Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
9ab4242
feat(kiloclaw): deliver Morning Briefing to configured channels
joshavant Apr 25, 2026
a10d0c1
refactor(kiloclaw): consolidate morning briefing delivery internals
joshavant Apr 25, 2026
f34cee8
fix(kiloclaw): avoid exposing delivery payloads in UI errors
joshavant Apr 25, 2026
cad73c2
fix(kiloclaw): stabilize morning briefing warmup transitions
joshavant Apr 25, 2026
31606b5
fix(kiloclaw): remove warmup disabled-state flap
joshavant Apr 25, 2026
1c6e2ca
fix(kiloclaw): polish morning briefing status metadata UI
joshavant Apr 25, 2026
5347626
fix(kiloclaw): align morning briefing metadata layout
joshavant Apr 25, 2026
6f8ff8a
fix(kiloclaw): format last delivery provider labels
joshavant Apr 25, 2026
948a8ab
fix(kiloclaw): simplify morning briefing failures section
joshavant Apr 25, 2026
7f998f7
style(web): apply formatting for morning briefing UI
joshavant Apr 25, 2026
7cbc6c9
fix(kiloclaw): preserve links with parentheses in delivery text
joshavant Apr 25, 2026
5850026
fix(kiloclaw): stop retrying run requests on timeout
joshavant Apr 25, 2026
bf0f251
test(web): lock warmup state against disabled flap
joshavant Apr 25, 2026
8055b29
fix(kiloclaw): sanitize stored morning briefing delivery errors
joshavant Apr 25, 2026
9e3985c
fix(kiloclaw): tighten briefing run timeout and delivery observability
joshavant Apr 25, 2026
668d62b
refactor(kiloclaw): share morning briefing delivery constants
joshavant Apr 25, 2026
980531b
Revert "refactor(kiloclaw): share morning briefing delivery constants"
joshavant Apr 25, 2026
dfedfa9
refactor(kiloclaw): dedupe delivery enums via shared constants
joshavant Apr 27, 2026
572c9dc
fix(kiloclaw): import delivery channels from constants module
joshavant Apr 27, 2026
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
164 changes: 110 additions & 54 deletions apps/web/src/app/(app)/claw/components/SettingsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,23 @@ import {
FileCode,
Hash,
Info,
Newspaper,
RotateCcw,
Save,
Settings,
ShieldCheck,
Square,
X,
} from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { OpenclawImportCard } from './OpenclawImportCard';

import { usePostHog } from 'posthog-js/react';
import { toast } from 'sonner';
import { useModelSelectorList } from '@/app/api/openrouter/hooks';
import { useUser } from '@/hooks/useUser';
import { ModelCombobox, type ModelOption } from '@/components/shared/ModelCombobox';
import type { KiloClawDashboardStatus } from '@/lib/kiloclaw/types';
import type { KiloClawDashboardStatus, MorningBriefingStatusLite } from '@/lib/kiloclaw/types';
import { calverAtLeast, cleanVersion } from '@/lib/kiloclaw/version';
import type { useKiloClawMutations } from '@/hooks/useKiloClaw';
import {
Expand Down Expand Up @@ -66,6 +67,7 @@ import { Switch } from '@/components/ui/switch';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { DetailTile } from './DetailTile';
import { EMBEDDING_MODELS, DEFAULT_EMBEDDING_MODEL } from './embeddingModels';
import { deriveMorningBriefingCardState } from './morning-briefing-card-state';

import { getEntriesByCategory } from '@kilocode/kiloclaw-secret-catalog';
import { SecretEntrySection } from './SecretEntrySection';
Expand Down Expand Up @@ -500,24 +502,7 @@ function MorningBriefingCard({
actionsReady,
}: {
mutations: ClawMutations;
briefingStatus:
| {
enabled?: boolean;
desiredEnabled?: boolean;
observedEnabled?: boolean | null;
reconcileState?: 'idle' | 'in_progress' | 'succeeded' | 'failed';
lastReconcileAction?: 'enable' | 'disable' | null;
code?: string;
cron?: string;
timezone?: string;
lastGeneratedDate?: string | null;
sourceReadiness?: {
github: { configured: boolean; summary: string };
linear: { configured: boolean; summary: string };
web: { configured: boolean; summary: string };
};
}
| undefined;
briefingStatus: MorningBriefingStatusLite | undefined;
fallbackReadiness: {
githubConfigured: boolean;
linearConfigured: boolean;
Expand Down Expand Up @@ -553,11 +538,14 @@ function MorningBriefingCard({
} as const);

const hasSchedule = Boolean(briefingStatus?.cron && briefingStatus?.timezone);
const desiredEnabled = briefingStatus?.desiredEnabled ?? briefingStatus?.enabled ?? false;
const observedEnabled = briefingStatus?.observedEnabled ?? briefingStatus?.enabled ?? false;
const { desiredEnabled, observedEnabled, hasResolvedBriefingToggleState, isWarmupState } =
deriveMorningBriefingCardState({
isRunning,
actionsReady,
briefingStatus,
});
const reconcileState = briefingStatus?.reconcileState ?? 'idle';
const lastReconcileAction = briefingStatus?.lastReconcileAction ?? null;
const isWarmupState = isRunning && actionsReady === false;
const isTransitioning =
reconcileState === 'in_progress' ||
mutations.enableMorningBriefing.isPending ||
Expand Down Expand Up @@ -596,8 +584,9 @@ function MorningBriefingCard({

return observedEnabled ? 'Enabled' : 'Disabled';
})();
const statusVariant =
statusLabel === 'Instance Stopped'
const statusVariant = isWarmupState
? 'secondary'
: statusLabel === 'Instance Stopped'
? 'secondary'
: observedEnabled || (isTransitioning && desiredEnabled)
? 'default'
Expand All @@ -623,25 +612,73 @@ function MorningBriefingCard({
const showScheduleDetails = !isWarmupState && hasSchedule && desiredEnabled;
const controlsEnabled = actionsReady && !isWarmupState;
const canUseBriefingControls = controlsEnabled && desiredEnabled;
const lastDelivery = briefingStatus?.lastDelivery ?? [];
const showLastDelivery =
!isWarmupState && actionsReady && hasResolvedBriefingToggleState && lastDelivery.length > 0;
const deliveryChannelLabel = {
telegram: 'Telegram',
discord: 'Discord',
slack: 'Slack',
} as const;
const deliveryStatusLabel = {
sent: 'Sent',
skipped: 'Skipped',
failed: 'Failed',
} as const;
const deliveryReasonLabel = {
missing_target: 'Missing target',
ambiguous_target: 'Ambiguous target',
send_failed: 'Send failed',
config_unavailable: 'Config unavailable',
} as const;

return (
<div className="rounded-lg border px-4 py-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="flex items-center gap-2">
<p className="text-sm font-medium">Morning Briefing</p>
<Badge variant={statusVariant}>{statusLabel}</Badge>
<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" />
<div>
<div className="flex items-center gap-2">
<p className="text-sm font-medium">Morning Briefing</p>
<Badge variant={statusVariant} className="px-1.5 py-0 text-[10px] leading-4">
{statusLabel}
</Badge>
</div>
{showScheduleDetails && briefingStatus?.cron && briefingStatus?.timezone && (
<p className="text-muted-foreground text-xs">
{formatMorningBriefingSchedule(briefingStatus.cron, briefingStatus.timezone)}
</p>
)}
{showScheduleDetails && (
<p className="text-muted-foreground text-xs">
Last generated: {briefingStatus?.lastGeneratedDate ?? '(none)'}
</p>
)}
{showLastDelivery && (
<p className="text-muted-foreground text-xs">
Last delivery:{' '}
{lastDelivery
.map(entry => {
const channel = deliveryChannelLabel[entry.channel] ?? entry.channel;
const status = deliveryStatusLabel[entry.status] ?? entry.status;
const reason = entry.reason
? (deliveryReasonLabel[entry.reason] ?? entry.reason)
: undefined;
return reason ? `${channel} (${status}: ${reason})` : `${channel} (${status})`;
})
.join(' • ')}
</p>
)}

{isWarmupState && (
<p className="text-muted-foreground mt-2 text-xs">
Instance is still warming up. Morning Briefing controls will become available once
the gateway is fully ready.
</p>
)}

<p className="text-muted-foreground mt-3 text-xs">{sourceSummaryText}</p>
</div>
{showScheduleDetails && briefingStatus?.cron && briefingStatus?.timezone && (
<p className="text-muted-foreground text-xs">
{formatMorningBriefingSchedule(briefingStatus.cron, briefingStatus.timezone)}
</p>
)}
{showScheduleDetails && (
<p className="text-muted-foreground text-xs">
Last generated: {briefingStatus?.lastGeneratedDate ?? '(none)'}
</p>
)}
</div>
<div className="flex flex-wrap gap-2">
<Button
Expand Down Expand Up @@ -712,21 +749,12 @@ function MorningBriefingCard({
</div>
</div>

{isWarmupState && (
<p className="text-muted-foreground mt-2 text-xs">
Instance is still warming up. Morning Briefing controls will become available once the
gateway is fully ready.
</p>
)}

{!desiredEnabled && controlsEnabled && (
<p className="text-muted-foreground mt-2 text-xs">
Enable Morning Briefing to get a personalized briefing everyday.
</p>
)}

<p className="text-muted-foreground mt-3 text-xs">{sourceSummaryText}</p>

{requestedDay && (
<div className="mt-3">
{isReading ? (
Expand Down Expand Up @@ -1314,10 +1342,40 @@ export function SettingsTab({
isControllerVersionError,
} = useClawUpdateAvailable(status);
const { data: myPin } = useClawMyPin();
const { data: morningBriefingStatus } = useClawMorningBriefingStatus(true);
const { data: gatewayReady } = useClawGatewayReady(isRunning);
const morningBriefingStatusQuery = useClawMorningBriefingStatus(isRunning);
const morningBriefingStatus = morningBriefingStatusQuery.data;
const gatewayReadyQuery = useClawGatewayReady(isRunning);
const gatewayReady = gatewayReadyQuery.data;
const [confirmDestroy, setConfirmDestroy] = useState(false);
const [confirmRestore, setConfirmRestore] = useState(false);
const [bootStartedAtMs, setBootStartedAtMs] = useState<number | null>(null);
const previousStatusRef = useRef(status.status);

useEffect(() => {
const previousStatus = previousStatusRef.current;
if (status.status === 'running' && previousStatus !== 'running') {
setBootStartedAtMs(Date.now());
}
if (status.status !== 'running' && previousStatus === 'running') {
setBootStartedAtMs(null);
}
previousStatusRef.current = status.status;
}, [status.status]);

const hasFreshGatewayReady =
isRunning &&
(bootStartedAtMs === null || gatewayReadyQuery.dataUpdatedAt >= bootStartedAtMs) &&
gatewayReadyQuery.dataUpdatedAt > 0;
const hasFreshMorningBriefingStatus =
isRunning &&
(bootStartedAtMs === null || morningBriefingStatusQuery.dataUpdatedAt >= bootStartedAtMs) &&
morningBriefingStatusQuery.dataUpdatedAt > 0;
const morningBriefingActionsReady =
isRunning &&
hasFreshGatewayReady &&
hasFreshMorningBriefingStatus &&
gatewayReady?.ready === true &&
gatewayReady?.settled === true;
const hasModelSelectionError = isRunning && isControllerVersionError;
const modelSelectionError = hasModelSelectionError
? 'Failed to load the running OpenClaw version. Retry before changing the default model.'
Expand Down Expand Up @@ -1867,9 +1925,7 @@ export function SettingsTab({
mutations={mutations}
briefingStatus={morningBriefingStatus}
isRunning={isRunning}
actionsReady={
isRunning && gatewayReady?.ready === true && gatewayReady?.settled === true
}
actionsReady={morningBriefingActionsReady}
fallbackReadiness={{
githubConfigured: configuredSecrets.github ?? false,
linearConfigured: configuredSecrets.linear ?? false,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, expect, test } from '@jest/globals';
import { deriveMorningBriefingCardState } from './morning-briefing-card-state';

describe('deriveMorningBriefingCardState', () => {
test('keeps warmup state when gateway_warming_up payload coexists with stale enabled=false', () => {
const state = deriveMorningBriefingCardState({
isRunning: true,
actionsReady: true,
briefingStatus: {
code: 'gateway_warming_up',
enabled: false,
desiredEnabled: false,
observedEnabled: false,
reconcileState: 'in_progress',
},
});

expect(state.isWarmupState).toBe(true);
expect(state.isGatewayWarmupStatus).toBe(true);
});

test('exits warmup when actions are ready and status is resolved without warmup code', () => {
const state = deriveMorningBriefingCardState({
isRunning: true,
actionsReady: true,
briefingStatus: {
enabled: true,
desiredEnabled: true,
observedEnabled: true,
reconcileState: 'succeeded',
},
});

expect(state.isWarmupState).toBe(false);
expect(state.desiredEnabled).toBe(true);
expect(state.observedEnabled).toBe(true);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { MorningBriefingStatusLite } from '@/lib/kiloclaw/types';

export type MorningBriefingCardStateInput = {
isRunning: boolean;
actionsReady: boolean;
briefingStatus: MorningBriefingStatusLite | undefined;
};

export type MorningBriefingCardState = {
desiredEnabled: boolean;
observedEnabled: boolean;
hasResolvedBriefingToggleState: boolean;
isGatewayWarmupStatus: boolean;
isWarmupState: boolean;
};

export function deriveMorningBriefingCardState(
input: MorningBriefingCardStateInput
): MorningBriefingCardState {
const desiredEnabledValue = input.briefingStatus?.desiredEnabled ?? input.briefingStatus?.enabled;
const observedEnabledValue =
input.briefingStatus?.observedEnabled ?? input.briefingStatus?.enabled;
const isGatewayWarmupStatus = input.briefingStatus?.code === 'gateway_warming_up';
const hasResolvedBriefingToggleState =
typeof desiredEnabledValue === 'boolean' && typeof observedEnabledValue === 'boolean';
const desiredEnabled = desiredEnabledValue ?? false;
const observedEnabled = observedEnabledValue ?? false;
const isWarmupState =
input.isRunning &&
(input.actionsReady === false || isGatewayWarmupStatus || !hasResolvedBriefingToggleState);

return {
desiredEnabled,
observedEnabled,
hasResolvedBriefingToggleState,
isGatewayWarmupStatus,
isWarmupState,
};
}
23 changes: 21 additions & 2 deletions apps/web/src/hooks/useKiloClaw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,11 @@ export function useKiloClawMutations() {
]);
};

const clearGatewayAndMorningBriefingCaches = () => {
queryClient.removeQueries({ queryKey: trpc.kiloclaw.gatewayReady.queryKey() });
queryClient.removeQueries({ queryKey: trpc.kiloclaw.getMorningBriefingStatus.queryKey() });
};

// Wipe all instance-scoped caches so no stale data (e.g. gatewayReady
// from the old instance) bleeds into a subsequent re-provision flow.
// removeQueries drops the cached payload entirely; invalidateQueries
Expand All @@ -183,13 +188,25 @@ export function useKiloClawMutations() {
};

return {
start: useMutation(trpc.kiloclaw.start.mutationOptions({ onSuccess: invalidateStatus })),
start: useMutation(
trpc.kiloclaw.start.mutationOptions({
onSuccess: async () => {
clearGatewayAndMorningBriefingCaches();
await invalidateStatus();
},
})
),
stop: useMutation(trpc.kiloclaw.stop.mutationOptions({ onSuccess: invalidateStatus })),
destroy: useMutation(
trpc.kiloclaw.destroy.mutationOptions({ onSuccess: resetAllInstanceState })
),
provision: useMutation(
trpc.kiloclaw.provision.mutationOptions({ onSuccess: invalidateStatusAndBilling })
trpc.kiloclaw.provision.mutationOptions({
onSuccess: async () => {
clearGatewayAndMorningBriefingCaches();
await invalidateStatusAndBilling();
},
})
),
cycleInboundEmailAddress: useMutation(
trpc.kiloclaw.cycleInboundEmailAddress.mutationOptions({ onSuccess: invalidateStatus })
Expand Down Expand Up @@ -222,6 +239,7 @@ export function useKiloClawMutations() {
restartMachine: useMutation(
trpc.kiloclaw.restartMachine.mutationOptions({
onSuccess: async () => {
clearGatewayAndMorningBriefingCaches();
await invalidateStatus();
await queryClient.invalidateQueries({
queryKey: trpc.kiloclaw.gatewayStatus.queryKey(),
Expand All @@ -232,6 +250,7 @@ export function useKiloClawMutations() {
restartOpenClaw: useMutation(
trpc.kiloclaw.restartOpenClaw.mutationOptions({
onSuccess: async () => {
clearGatewayAndMorningBriefingCaches();
await invalidateStatus();
await queryClient.invalidateQueries({
queryKey: trpc.kiloclaw.gatewayStatus.queryKey(),
Expand Down
Loading
Loading