mirror of
https://github.com/daggerhashimoto/openclaw-nerve
synced 2026-04-21 10:37:17 +00:00
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:
parent
e9eabc80cf
commit
d9b56f7964
8 changed files with 152 additions and 43 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -40,3 +40,4 @@ tts-config.json
|
|||
voice-phrases.json
|
||||
coverage/
|
||||
.test-tasks/
|
||||
src/test/*.js
|
||||
|
|
|
|||
|
|
@ -18,5 +18,6 @@
|
|||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["../server"]
|
||||
"include": ["../server"],
|
||||
"exclude": ["../server/**/*.test.ts"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
49
server/routes/channels.ts
Normal 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;
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in a new issue