Skip to content
Open
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
11 changes: 11 additions & 0 deletions apps/mobile/src/lib/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export function setActiveChatInstance(instanceId: string | null) {
// Keep in sync with the `data` payloads emitted by:
// - services/notifications/src/dos/NotificationChannelDO.ts (chat)
// - services/notifications/src/lib/notifications-service.ts (instance-lifecycle)
// - services/notifications/src/lib/scheduled-action-push.ts (scheduled-action)
const notificationDataSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('chat'),
Expand All @@ -37,6 +38,16 @@ const notificationDataSchema = z.discriminatedUnion('type', [
event: z.enum(['ready', 'start_failed']),
instanceId: z.string().min(1),
}),
z.object({
type: z.literal('scheduled-action'),
event: z.enum([
'scheduled_restart_notice',
'scheduled_restart_cancelled',
'scheduled_version_change_notice',
'scheduled_version_change_cancelled',
]),
instanceId: z.string().min(1),
}),
]);

type NotificationData = z.infer<typeof notificationDataSchema>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { Alert, AlertDescription } from '@/components/ui/alert';
import { Card, CardContent } from '@/components/ui/card';
import { InstanceControls } from './InstanceControls';
import { InstanceTab } from './InstanceTab';
import { KiloClawScheduledActionBanner } from './KiloClawScheduledActionBanner';
import { useClawContext } from './ClawContext';

export function ClawInstanceOverview({
Expand Down Expand Up @@ -63,6 +64,11 @@ export function ClawInstanceOverview({
</Alert>
)}

<KiloClawScheduledActionBanner
scheduledAction={status.scheduledAction}
instanceName={status.name}
/>

<Card>
<CardContent className="border-b p-5">
<InstanceControls
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ const fakeStatus = {
instanceId: 'fake-instance',
inboundEmailAddress: null,
inboundEmailEnabled: false,
scheduledAction: null,
} satisfies PopulatedClawStatus;

export function ClawOnboardingFakeWalkthrough({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ function createStatus(status: KiloClawDashboardStatus['status']): KiloClawDashbo
instanceId: null,
inboundEmailAddress: null,
inboundEmailEnabled: false,
scheduledAction: null,
};
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
'use client';

/**
* In-workspace banner that surfaces the soonest pending scheduled
* admin action on this user's instance. Reads from the `scheduledAction`
* field on `kiloclaw.getStatus`. The field is null when nothing is
* pending, so the banner self-hides.
*
* Cancellation does NOT render here: once an action is cancelled the
* `scheduledAction` field returns null and the banner disappears. Users
* learn about the cancellation via email and mobile push (the
* `cancelled`-kind notifications), which are dispatched only when a
* notice was previously sent.
*/

import { CalendarClock } from 'lucide-react';
import { Alert, AlertDescription } from '@/components/ui/alert';
import type { KiloClawScheduledActionStatusBlock } from '@/lib/kiloclaw/types';

type Props = {
scheduledAction: KiloClawScheduledActionStatusBlock | null;
/**
* The user's name for the bot, when set. Renders as "Your bot
* **<name>**". Null = use the generic "Your bot" phrasing (matches
* the email's behavior when no name is set).
*/
instanceName: string | null;
};

function formatScheduledAt(iso: string): string {
// INTENTIONAL: this banner renders LOCAL time + the user's timezone
// abbreviation (e.g., "5/4/2026, 6:55 PM PDT"). The email and push
// surfaces render the same instant in UTC ("May 4, 2026, 6:55 PM
// UTC") — see apps/web/src/app/api/internal/kiloclaw/
// scheduled-action-side-effects/route.ts and
// services/notifications/src/lib/scheduled-action-push.ts. The two
// strings will not match character-for-character, but each labels
// its zone explicitly, so a user comparing the banner to the email
// knows they're seeing the same instant in two zones — the banner
// is local-friendly, the email is portable across recipients in
// different zones.
//
// This is a 'use client' component, so toLocaleString runs in the
// user's browser, not on the server, and the runtime locale/zone
// are stable per user.
try {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const dateStr = d.toLocaleString();
const tzPart = new Intl.DateTimeFormat(undefined, { timeZoneName: 'short' })
.formatToParts(d)
.find(p => p.type === 'timeZoneName')?.value;
return tzPart ? `${dateStr} ${tzPart}` : dateStr;
} catch {
return iso;
}
}

export function KiloClawScheduledActionBanner({ scheduledAction, instanceName }: Props) {
if (!scheduledAction) return null;

// Bake the period into the timestamp span so it doesn't wrap to its
// own line when the column narrows. Same fix as the per-instance
// admin indicator.
const when = `${formatScheduledAt(scheduledAction.scheduledAt)}.`;
const isVersionChange = scheduledAction.actionType === 'version_change';
const targetLabel =
isVersionChange && scheduledAction.targetImageTag
? scheduledAction.targetOpenclawVersion
? `${scheduledAction.targetImageTag} (OpenClaw ${scheduledAction.targetOpenclawVersion})`
: scheduledAction.targetImageTag
: null;
const namedBot = instanceName?.trim() ? (
<>
Your bot <strong>{instanceName.trim()}</strong>
</>
) : (
<>Your bot</>
);

return (
<Alert className="border-yellow-500/30 bg-yellow-500/5">
<CalendarClock className="h-4 w-4 text-yellow-400" />
<AlertDescription>
{isVersionChange ? (
<>
{namedBot} is scheduled to upgrade
{targetLabel ? (
<>
{' '}
to <code className="font-mono text-xs">{targetLabel}</code>
</>
) : null}{' '}
at <span className="font-mono">{when}</span> It will be briefly offline during the
upgrade.
</>
) : (
<>
{namedBot} is scheduled to restart at <span className="font-mono">{when}</span> It will
be briefly offline during the restart.
</>
)}
</AlertDescription>
</Alert>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ const baseStatus: KiloClawDashboardStatus = {
instanceId: null,
inboundEmailAddress: 'amber-river-quiet-maple@kiloclaw.ai',
inboundEmailEnabled: true,
scheduledAction: null,
};

describe('withStatusQueryBoundary', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const baseStatus: KiloClawDashboardStatus = {
instanceId: 'instance-1',
inboundEmailAddress: 'amber-river-quiet-maple@kiloclaw.ai',
inboundEmailEnabled: true,
scheduledAction: null,
};

function createStatus(instanceId: string | null = 'instance-1'): KiloClawDashboardStatus {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,12 @@ import { toast } from 'sonner';
import type { inferRouterOutputs } from '@trpc/server';
import type { RootRouter } from '@/routers/root-router';
import type { AdminKiloclawInstance } from '@/routers/admin-kiloclaw-instances-router';
import { defaultScheduledAt } from '@/lib/kiloclaw/scheduled-action-form';
import {
defaultScheduledAt,
defaultNotifyFormState,
type NotifyFormState,
} from '@/lib/kiloclaw/scheduled-action-form';
import { ScheduleNotifyFields } from '../KiloclawScheduler/ScheduleNotifyFields';

type RouterOutputs = inferRouterOutputs<RootRouter>;
type ListVersionsItem = RouterOutputs['admin']['kiloclawVersions']['listVersions']['items'][number];
Expand Down Expand Up @@ -74,6 +79,7 @@ export function BulkChangeVersionDialog({
const [failedSectionOpen, setFailedSectionOpen] = useState(true);
const [mode, setMode] = useState<'now' | 'scheduled'>('now');
const [scheduledAt, setScheduledAt] = useState<string>(defaultScheduledAt);
const [notify, setNotify] = useState<NotifyFormState>(defaultNotifyFormState);

// Reset form whenever the dialog reopens. Keeps state from leaking
// between independent admin actions.
Expand All @@ -84,6 +90,7 @@ export function BulkChangeVersionDialog({
setConfirmInput('');
setResult(null);
setMode('now');
setNotify(defaultNotifyFormState());
}
}, [open]);

Expand Down Expand Up @@ -220,6 +227,11 @@ export function BulkChangeVersionDialog({
imageTag: targetTag,
overridePins,
scheduledAt: local.toISOString(),
notify: notify.notify,
noticeLeadHours: notify.noticeLeadHours,
noticeSubject: notify.noticeSubject,
noticeBody: notify.noticeBody,
noticeChannels: notify.noticeChannels,
});
};

Expand Down Expand Up @@ -280,15 +292,7 @@ export function BulkChangeVersionDialog({
</AlertDescription>
</Alert>
</TabsContent>
<TabsContent value="scheduled" className="mt-3 space-y-2">
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
Notifications aren't implemented yet — end users get no warning before their
session is interrupted at the scheduled time. Use cautiously on customer
instances until the notifications work lands.
</AlertDescription>
</Alert>
<TabsContent value="scheduled" className="mt-3 space-y-3">
<div className="space-y-2">
<Label htmlFor="bulk-scheduled-at">Scheduled at (local time)</Label>
<Input
Expand All @@ -308,6 +312,12 @@ export function BulkChangeVersionDialog({
Per-instance outcome (applied / skipped / failed) shows up in the Scheduler tab as
the action progresses.
</p>
<ScheduleNotifyFields
idPrefix="bulk"
state={notify}
onChange={setNotify}
disabled={isPending}
/>
</TabsContent>
</Tabs>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,12 @@ import {
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { toast } from 'sonner';
import { toastPinMutationResult } from '@/lib/kiloclaw/pin-sync-toast';
import { defaultScheduledAt } from '@/lib/kiloclaw/scheduled-action-form';
import {
defaultScheduledAt,
defaultNotifyFormState,
type NotifyFormState,
} from '@/lib/kiloclaw/scheduled-action-form';
import { ScheduleNotifyFields } from '../KiloclawScheduler/ScheduleNotifyFields';
import { AdminFileEditor } from './AdminFileEditor';
import { KiloCliRunCard } from './KiloCliRunCard';
import { BumpVolumeTo15GbButton } from './BumpVolumeTo15GbDialog';
Expand Down Expand Up @@ -1261,6 +1266,8 @@ export function KiloclawInstanceDetail({ instanceId }: { instanceId: string }) {
const [changeVersionMode, setChangeVersionMode] = useState<'now' | 'scheduled'>('now');
const [changeVersionScheduledAt, setChangeVersionScheduledAt] =
useState<string>(defaultScheduledAt);
const [changeVersionNotify, setChangeVersionNotify] =
useState<NotifyFormState>(defaultNotifyFormState);
const [upgradeLatestConfirmOpen, setUpgradeLatestConfirmOpen] = useState(false);
const [resizePhase, setResizePhase] = useState<
'idle' | 'stopping' | 'resizing' | 'starting' | 'waiting' | 'done' | 'error'
Expand Down Expand Up @@ -3432,6 +3439,7 @@ export function KiloclawInstanceDetail({ instanceId }: { instanceId: string }) {
if (!open) {
setChangeVersionSelectedTag('');
setChangeVersionMode('now');
setChangeVersionNotify(defaultNotifyFormState());
}
}}
>
Expand Down Expand Up @@ -3498,15 +3506,7 @@ export function KiloclawInstanceDetail({ instanceId }: { instanceId: string }) {
<TabsContent value="now" className="text-muted-foreground mt-3 text-xs">
Applies immediately. End-user session is interrupted with no notice.
</TabsContent>
<TabsContent value="scheduled" className="mt-3 space-y-2">
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
Notifications aren't implemented yet — the end user gets no warning before
their session is interrupted at the scheduled time. Use cautiously on customer
instances until the notifications work lands.
</AlertDescription>
</Alert>
<TabsContent value="scheduled" className="mt-3 space-y-3">
<label htmlFor="change-version-scheduled-at" className="text-sm font-medium">
Scheduled at (local time)
</label>
Expand All @@ -3524,6 +3524,12 @@ export function KiloclawInstanceDetail({ instanceId }: { instanceId: string }) {
Fires on the next instance reconcile alarm tick after this time (cadence ~5
minutes for running instances). Treat as a "no earlier than" bound.
</p>
<ScheduleNotifyFields
idPrefix="change-version"
state={changeVersionNotify}
onChange={setChangeVersionNotify}
disabled={isSchedulingVersionChange}
/>
</TabsContent>
</Tabs>

Expand Down Expand Up @@ -3619,6 +3625,11 @@ export function KiloclawInstanceDetail({ instanceId }: { instanceId: string }) {
imageTag: changeVersionSelectedTag,
overridePins: !!changeVersionPinData,
scheduledAt: local.toISOString(),
notify: changeVersionNotify.notify,
noticeLeadHours: changeVersionNotify.noticeLeadHours,
noticeSubject: changeVersionNotify.noticeSubject,
noticeBody: changeVersionNotify.noticeBody,
noticeChannels: changeVersionNotify.noticeChannels,
});
}}
disabled={
Expand Down
Loading