feat: UX improvements for cron management

- Rewrite delivery section: 'When done' framing, 'Run silently' default,
  auto-detected channels from /api/channels, context-aware placeholders
- Distinguish task vs delivery failures in cron list (green+warning vs red)
- Replace emoji icons with Lucide (Timer, CornerDownRight) in session panel
- New GET /api/channels endpoint (reads configured channels from openclaw.json)
- Fix server tsconfig: exclude test files to prevent rootDir expansion on clean builds
This commit is contained in:
daggerhashimoto 2026-02-26 22:04:37 +01:00
parent e9eabc80cf
commit d9b56f7964
8 changed files with 152 additions and 43 deletions

1
.gitignore vendored
View file

@ -40,3 +40,4 @@ tts-config.json
voice-phrases.json
coverage/
.test-tasks/
src/test/*.js

View file

@ -18,5 +18,6 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["../server"]
"include": ["../server"],
"exclude": ["../server/**/*.test.ts"]
}

View file

@ -32,6 +32,7 @@ import serverInfoRoutes from './routes/server-info.js';
import codexLimitsRoutes from './routes/codex-limits.js';
import claudeCodeLimitsRoutes from './routes/claude-code-limits.js';
import versionRoutes from './routes/version.js';
import channelsRoutes from './routes/channels.js';
import versionCheckRoutes from './routes/version-check.js';
import gatewayRoutes from './routes/gateway.js';
import connectDefaultsRoutes from './routes/connect-defaults.js';
@ -117,7 +118,7 @@ const routes = [
codexLimitsRoutes, claudeCodeLimitsRoutes, versionRoutes, versionCheckRoutes,
gatewayRoutes, connectDefaultsRoutes,
workspaceRoutes, cronsRoutes, sessionsRoutes, skillsRoutes, filesRoutes, apiKeysRoutes,
voicePhrasesRoutes, fileBrowserRoutes,
voicePhrasesRoutes, fileBrowserRoutes, channelsRoutes,
];
for (const route of routes) app.route('/', route);

49
server/routes/channels.ts Normal file
View file

@ -0,0 +1,49 @@
/**
* GET /api/channels List messaging channels configured in OpenClaw.
*
* Reads channel keys from ~/.openclaw/openclaw.json. Returns an array
* of channel names (e.g. ["whatsapp", "discord"]).
* Cached for 5 minutes to avoid repeated disk reads.
*/
import { Hono } from 'hono';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { rateLimitGeneral } from '../middleware/rate-limit.js';
interface ChannelsCache {
channels: string[];
checkedAt: number;
}
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
let cache: ChannelsCache | null = null;
/** Read configured channel names from openclaw.json. */
async function readConfiguredChannels(): Promise<string[]> {
try {
const configPath = join(homedir(), '.openclaw', 'openclaw.json');
const raw = await readFile(configPath, 'utf-8');
const config = JSON.parse(raw) as { channels?: Record<string, unknown> };
if (!config.channels || typeof config.channels !== 'object') return [];
return Object.keys(config.channels).filter(k => k !== 'webchat');
} catch {
return [];
}
}
const app = new Hono();
app.get('/api/channels', rateLimitGeneral, async (c) => {
const now = Date.now();
if (cache && now - cache.checkedAt < CACHE_TTL_MS) {
return c.json({ channels: cache.channels });
}
const channels = await readConfiguredChannels();
cache = { channels, checkedAt: now };
return c.json({ channels });
});
export default app;

View file

@ -6,7 +6,7 @@ import { cn } from '@/lib/utils';
import { AnimatedNumber } from '@/components/ui/AnimatedNumber';
import { PROGRESS_BAR_TRANSITION } from '@/lib/progress-colors';
import { getStatusBadgeText, getStatusBadgeClasses } from './statusUtils';
import { ChevronRight, ChevronDown, EllipsisVertical, PenLine } from 'lucide-react';
import { ChevronRight, ChevronDown, EllipsisVertical, PenLine, Timer, CornerDownRight } from 'lucide-react';
import { SessionInfoPanel } from './SessionInfoPanel';
// Pre-defined color configs to avoid object creation during render
@ -258,8 +258,8 @@ export const SessionNode = memo(function SessionNode({
"text-[10px] font-bold flex-1 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap cursor-help",
isCronRun ? "text-muted-foreground font-normal" : "text-foreground"
)}>
{isCron && <span className="text-purple mr-1" title="Cron job"></span>}
{isCronRun && <span className="text-purple/60 mr-1" title="Cron run"></span>}
{isCron && <Timer size={11} className="text-purple mr-1 inline shrink-0" aria-label="Cron job" />}
{isCronRun && <CornerDownRight size={10} className="text-purple/60 mr-1 inline shrink-0" aria-label="Cron run" />}
{label}
</span>
</SessionInfoPanel>

View file

@ -31,6 +31,7 @@ export interface CronJob {
lastRun?: string;
lastStatus?: string;
lastError?: string;
lastDeliveryStatus?: string;
}
export interface CronRun {
@ -72,6 +73,7 @@ function normalizeJob(j: Record<string, unknown>): CronJob {
: undefined,
lastStatus: state.lastStatus as string | undefined,
lastError: state.lastError as string | undefined,
lastDeliveryStatus: state.lastDeliveryStatus as string | undefined,
};
}

View file

@ -36,14 +36,27 @@ const INTERVAL_PRESETS = [
{ value: '86400000', label: '24 hours' },
];
const DELIVERY_CHANNELS = [
{ value: '', label: 'Default' },
{ value: 'whatsapp', label: 'WhatsApp' },
{ value: 'telegram', label: 'Telegram' },
{ value: 'discord', label: 'Discord' },
{ value: 'signal', label: 'Signal' },
{ value: 'slack', label: 'Slack' },
];
const CHANNEL_LABELS: Record<string, string> = {
whatsapp: 'WhatsApp',
telegram: 'Telegram',
discord: 'Discord',
signal: 'Signal',
slack: 'Slack',
irc: 'IRC',
googlechat: 'Google Chat',
imessage: 'iMessage',
};
const CHANNEL_PLACEHOLDERS: Record<string, string> = {
whatsapp: '+905551234567',
telegram: '-100123456789 or @username',
discord: 'channel-id',
signal: '+905551234567',
slack: '#channel or @user',
irc: '#channel',
googlechat: 'space-id',
imessage: '+905551234567',
};
/** Strip the auto-appended delivery instruction from a prompt for clean editing */
function stripDeliveryInstruction(msg: string): string {
@ -73,15 +86,16 @@ export function CronDialog({ open, onClose, onSubmit, mode, initialData }: CronD
const [payloadKind, setPayloadKind] = useState<PayloadKind>(() => prefill?.payloadKind || 'agentTurn');
const [message, setMessage] = useState(() => prefill ? stripDeliveryInstruction(prefill.message || '') : '');
const [model, setModel] = useState(() => prefill?.model || '');
const [deliveryMode, setDeliveryMode] = useState<DeliveryMode>(() => prefill?.delivery?.mode === 'none' ? 'none' : 'announce');
const [deliveryMode, setDeliveryMode] = useState<DeliveryMode>(() => prefill?.delivery?.mode === 'announce' ? 'announce' : 'none');
const [deliveryChannel, setDeliveryChannel] = useState(() => prefill?.delivery?.channel || '');
const [deliveryTo, setDeliveryTo] = useState(() => prefill?.delivery?.to || '');
const [models, setModels] = useState<{ value: string; label: string }[]>([]);
const [availableChannels, setAvailableChannels] = useState<string[]>([]);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const dialogRef = useRef<HTMLDialogElement>(null);
// Fetch available models when dialog opens
// Fetch available models and configured channels when dialog opens
useEffect(() => {
if (!open) return;
fetch('/api/gateway/models')
@ -101,6 +115,14 @@ export function CronDialog({ open, onClose, onSubmit, mode, initialData }: CronD
.catch(() => {
setModels([{ value: '', label: 'Default model' }]);
});
fetch('/api/channels')
.then(r => r.json())
.then((data: { channels?: string[] }) => {
const ch = data.channels || [];
setAvailableChannels(ch);
})
.catch(() => setAvailableChannels([]));
// eslint-disable-next-line react-hooks/exhaustive-deps -- only run on open
}, [open]);
// Form state is initialized from props via useState initializers above.
@ -347,40 +369,57 @@ export function CronDialog({ open, onClose, onSubmit, mode, initialData }: CronD
{payloadKind === 'agentTurn' && (
<>
<div className="flex flex-col gap-1">
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">Delivery</span>
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">When done</span>
<InlineSelect inline
value={deliveryMode}
onChange={v => setDeliveryMode(v as DeliveryMode)}
options={[
{ value: 'announce', label: 'Announce result' },
{ value: 'none', label: 'Silent (no delivery)' },
{ value: 'announce', label: 'Send result to a channel' },
{ value: 'none', label: 'Run silently' },
]}
ariaLabel="Delivery mode"
/>
{deliveryMode === 'none' && (
<span className="text-[9px] text-muted-foreground/60 mt-0.5">Result stays in the session transcript check it anytime in Nerve.</span>
)}
</div>
{deliveryMode === 'announce' && (
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-1">
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">Channel</span>
<InlineSelect inline
value={deliveryChannel}
onChange={setDeliveryChannel}
options={DELIVERY_CHANNELS}
ariaLabel="Delivery channel"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="cron-deliver-to" className="text-[10px] uppercase tracking-wider text-muted-foreground">Deliver to (phone/JID/chat ID)</label>
<input
id="cron-deliver-to"
type="text"
value={deliveryTo}
onChange={e => setDeliveryTo(e.target.value)}
placeholder="+905551234567 or -100123456789:topic:42…"
className="font-mono text-[11px] bg-background border border-border/60 text-foreground px-2 py-1.5 outline-none focus:border-purple focus-visible:ring-2 focus-visible:ring-purple/50 focus-visible:ring-offset-0"
/>
</div>
{availableChannels.length === 0 ? (
<div className="text-[10px] text-orange">
No messaging channels configured. Set up a channel in OpenClaw config first, or switch to "Run silently".
</div>
) : (
<div className="flex flex-col gap-1">
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">Send via</span>
<InlineSelect inline
value={deliveryChannel}
onChange={setDeliveryChannel}
options={[
{ value: '', label: 'Select channel…' },
...availableChannels.map(ch => ({
value: ch,
label: CHANNEL_LABELS[ch] || ch,
})),
]}
ariaLabel="Delivery channel"
/>
</div>
)}
{deliveryChannel && (
<div className="flex flex-col gap-1">
<label htmlFor="cron-deliver-to" className="text-[10px] uppercase tracking-wider text-muted-foreground">Send to</label>
<input
id="cron-deliver-to"
type="text"
value={deliveryTo}
onChange={e => setDeliveryTo(e.target.value)}
placeholder={CHANNEL_PLACEHOLDERS[deliveryChannel] || 'recipient ID'}
className="font-mono text-[11px] bg-background border border-border/60 text-foreground px-2 py-1.5 outline-none focus:border-purple focus-visible:ring-2 focus-visible:ring-purple/50 focus-visible:ring-offset-0"
/>
</div>
)}
</div>
)}
</>

View file

@ -3,7 +3,7 @@
*/
import { useEffect, useState, useCallback, useRef } from 'react';
import { RefreshCw, Play, Plus, Trash2, Pencil, ChevronDown, ChevronRight, CheckCircle, XCircle, Circle, Loader2 } from 'lucide-react';
import { RefreshCw, Play, Plus, Trash2, Pencil, ChevronDown, ChevronRight, CheckCircle, XCircle, AlertTriangle, Circle, Loader2 } from 'lucide-react';
import { useCrons, type CronJob, type CronRun } from '../hooks/useCrons';
import { CronDialog } from './CronDialog';
@ -106,6 +106,11 @@ function CronRow({ job, onToggle, onRun, onDelete, onEdit, onFetchRuns }: {
const name = job.name || job.label || job.id;
const isSuccess = job.lastStatus === 'success' || job.lastStatus === 'ok' || job.lastStatus === 'finished';
// Detect delivery-only failures: task ran but delivery failed
const isDeliveryFailure = !isSuccess && job.lastError?.includes('Channel is required')
|| (!isSuccess && job.lastDeliveryStatus === 'error' && job.lastError?.includes('channel'))
|| (!isSuccess && job.lastError?.includes('delivery'));
const taskSucceeded = isSuccess || isDeliveryFailure;
return (
<div className="border-b border-border/40">
@ -128,9 +133,15 @@ function CronRow({ job, onToggle, onRun, onDelete, onEdit, onFetchRuns }: {
<div className="text-[10px] text-muted-foreground mt-0.5 flex items-center gap-1">
<span>Last run: {relativeTime(job.lastRun)}</span>
{job.lastStatus && (
<span className={`flex items-center gap-0.5 ${isSuccess ? 'text-green' : 'text-red'}`}>
{isSuccess ? <CheckCircle size={9} /> : <XCircle size={9} />} {job.lastStatus}
</span>
isDeliveryFailure ? (
<span className="flex items-center gap-0.5 text-orange" title="Task completed but delivery failed">
<CheckCircle size={9} className="text-green" /> <AlertTriangle size={9} /> delivery failed
</span>
) : (
<span className={`flex items-center gap-0.5 ${isSuccess ? 'text-green' : 'text-red'}`}>
{isSuccess ? <CheckCircle size={9} /> : <XCircle size={9} />} {job.lastStatus}
</span>
)
)}
</div>
)}
@ -142,11 +153,16 @@ function CronRow({ job, onToggle, onRun, onDelete, onEdit, onFetchRuns }: {
<span>Running</span>
</div>
)}
{job.lastError && !isSuccess && !running && (
{job.lastError && !taskSucceeded && !running && (
<div className="text-[10px] text-red/70 mt-0.5 truncate" title={job.lastError}>
{job.lastError}
</div>
)}
{isDeliveryFailure && !running && (
<div className="text-[10px] text-orange/70 mt-0.5 truncate" title={job.lastError}>
Delivery failed check channel config in cron settings
</div>
)}
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">