OPENCLAW
diff --git a/ui/src/ui/controllers/cron.ts b/ui/src/ui/controllers/cron.ts
index 836415eb69..190311bca6 100644
--- a/ui/src/ui/controllers/cron.ts
+++ b/ui/src/ui/controllers/cron.ts
@@ -55,7 +55,7 @@ export function buildCronSchedule(form: CronFormState) {
if (!Number.isFinite(ms)) {
throw new Error("Invalid run time.");
}
- return { kind: "at" as const, atMs: ms };
+ return { kind: "at" as const, at: new Date(ms).toISOString() };
}
if (form.scheduleKind === "every") {
const amount = toNumber(form.everyAmount, 0);
@@ -88,20 +88,8 @@ export function buildCronPayload(form: CronFormState) {
const payload: {
kind: "agentTurn";
message: string;
- deliver?: boolean;
- channel?: string;
- to?: string;
timeoutSeconds?: number;
} = { kind: "agentTurn", message };
- if (form.deliver) {
- payload.deliver = true;
- }
- if (form.channel) {
- payload.channel = form.channel;
- }
- if (form.to.trim()) {
- payload.to = form.to.trim();
- }
const timeoutSeconds = toNumber(form.timeoutSeconds, 0);
if (timeoutSeconds > 0) {
payload.timeoutSeconds = timeoutSeconds;
@@ -118,6 +106,16 @@ export async function addCronJob(state: CronState) {
try {
const schedule = buildCronSchedule(state.cronForm);
const payload = buildCronPayload(state.cronForm);
+ const delivery =
+ state.cronForm.sessionTarget === "isolated" &&
+ state.cronForm.payloadKind === "agentTurn" &&
+ state.cronForm.deliveryMode
+ ? {
+ mode: state.cronForm.deliveryMode === "announce" ? "announce" : "none",
+ channel: state.cronForm.deliveryChannel.trim() || "last",
+ to: state.cronForm.deliveryTo.trim() || undefined,
+ }
+ : undefined;
const agentId = state.cronForm.agentId.trim();
const job = {
name: state.cronForm.name.trim(),
@@ -128,10 +126,7 @@ export async function addCronJob(state: CronState) {
sessionTarget: state.cronForm.sessionTarget,
wakeMode: state.cronForm.wakeMode,
payload,
- isolation:
- state.cronForm.postToMainPrefix.trim() && state.cronForm.sessionTarget === "isolated"
- ? { postToMainPrefix: state.cronForm.postToMainPrefix.trim() }
- : undefined,
+ delivery,
};
if (!job.name) {
throw new Error("Name required.");
diff --git a/ui/src/ui/format.test.ts b/ui/src/ui/format.test.ts
index cc992ea09f..8e1f121ea6 100644
--- a/ui/src/ui/format.test.ts
+++ b/ui/src/ui/format.test.ts
@@ -1,5 +1,36 @@
import { describe, expect, it } from "vitest";
-import { stripThinkingTags } from "./format.ts";
+import { formatAgo, stripThinkingTags } from "./format.ts";
+
+describe("formatAgo", () => {
+ it("returns 'just now' for timestamps less than 60s in the future", () => {
+ expect(formatAgo(Date.now() + 30_000)).toBe("just now");
+ });
+
+ it("returns 'Xm from now' for future timestamps", () => {
+ expect(formatAgo(Date.now() + 5 * 60_000)).toBe("5m from now");
+ });
+
+ it("returns 'Xh from now' for future timestamps", () => {
+ expect(formatAgo(Date.now() + 3 * 60 * 60_000)).toBe("3h from now");
+ });
+
+ it("returns 'Xd from now' for future timestamps beyond 48h", () => {
+ expect(formatAgo(Date.now() + 3 * 24 * 60 * 60_000)).toBe("3d from now");
+ });
+
+ it("returns 'Xs ago' for recent past timestamps", () => {
+ expect(formatAgo(Date.now() - 10_000)).toBe("10s ago");
+ });
+
+ it("returns 'Xm ago' for past timestamps", () => {
+ expect(formatAgo(Date.now() - 5 * 60_000)).toBe("5m ago");
+ });
+
+ it("returns 'n/a' for null/undefined", () => {
+ expect(formatAgo(null)).toBe("n/a");
+ expect(formatAgo(undefined)).toBe("n/a");
+ });
+});
describe("stripThinkingTags", () => {
it("strips
… segments", () => {
diff --git a/ui/src/ui/format.ts b/ui/src/ui/format.ts
index d1073b8f80..812aaa3fb1 100644
--- a/ui/src/ui/format.ts
+++ b/ui/src/ui/format.ts
@@ -12,23 +12,22 @@ export function formatAgo(ms?: number | null): string {
return "n/a";
}
const diff = Date.now() - ms;
- if (diff < 0) {
- return "just now";
- }
- const sec = Math.round(diff / 1000);
+ const absDiff = Math.abs(diff);
+ const suffix = diff < 0 ? "from now" : "ago";
+ const sec = Math.round(absDiff / 1000);
if (sec < 60) {
- return `${sec}s ago`;
+ return diff < 0 ? "just now" : `${sec}s ago`;
}
const min = Math.round(sec / 60);
if (min < 60) {
- return `${min}m ago`;
+ return `${min}m ${suffix}`;
}
const hr = Math.round(min / 60);
if (hr < 48) {
- return `${hr}h ago`;
+ return `${hr}h ${suffix}`;
}
const day = Math.round(hr / 24);
- return `${day}d ago`;
+ return `${day}d ${suffix}`;
}
export function formatDurationMs(ms?: number | null): string {
diff --git a/ui/src/ui/presenter.ts b/ui/src/ui/presenter.ts
index a6738b6f8f..7c99380a86 100644
--- a/ui/src/ui/presenter.ts
+++ b/ui/src/ui/presenter.ts
@@ -53,7 +53,8 @@ export function formatCronState(job: CronJob) {
export function formatCronSchedule(job: CronJob) {
const s = job.schedule;
if (s.kind === "at") {
- return `At ${formatMs(s.atMs)}`;
+ const atMs = Date.parse(s.at);
+ return Number.isFinite(atMs) ? `At ${formatMs(atMs)}` : `At ${s.at}`;
}
if (s.kind === "every") {
return `Every ${formatDurationMs(s.everyMs)}`;
@@ -66,5 +67,14 @@ export function formatCronPayload(job: CronJob) {
if (p.kind === "systemEvent") {
return `System: ${p.text}`;
}
- return `Agent: ${p.message}`;
+ const base = `Agent: ${p.message}`;
+ const delivery = job.delivery;
+ if (delivery && delivery.mode !== "none") {
+ const target =
+ delivery.channel || delivery.to
+ ? ` (${delivery.channel ?? "last"}${delivery.to ? ` -> ${delivery.to}` : ""})`
+ : "";
+ return `${base} · ${delivery.mode}${target}`;
+ }
+ return base;
}
diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts
index 36fe4a77f1..27a1132bf2 100644
--- a/ui/src/ui/types.ts
+++ b/ui/src/ui/types.ts
@@ -425,7 +425,7 @@ export type SessionsPatchResult = {
};
export type CronSchedule =
- | { kind: "at"; atMs: number }
+ | { kind: "at"; at: string }
| { kind: "every"; everyMs: number; anchorMs?: number }
| { kind: "cron"; expr: string; tz?: string };
@@ -439,22 +439,13 @@ export type CronPayload =
message: string;
thinking?: string;
timeoutSeconds?: number;
- deliver?: boolean;
- provider?:
- | "last"
- | "whatsapp"
- | "telegram"
- | "discord"
- | "slack"
- | "signal"
- | "imessage"
- | "msteams";
- to?: string;
- bestEffortDeliver?: boolean;
};
-export type CronIsolation = {
- postToMainPrefix?: string;
+export type CronDelivery = {
+ mode: "none" | "announce";
+ channel?: string;
+ to?: string;
+ bestEffort?: boolean;
};
export type CronJobState = {
@@ -479,7 +470,7 @@ export type CronJob = {
sessionTarget: CronSessionTarget;
wakeMode: CronWakeMode;
payload: CronPayload;
- isolation?: CronIsolation;
+ delivery?: CronDelivery;
state?: CronJobState;
};
diff --git a/ui/src/ui/ui-types.ts b/ui/src/ui/ui-types.ts
index afb80c179b..7ce3c73998 100644
--- a/ui/src/ui/ui-types.ts
+++ b/ui/src/ui/ui-types.ts
@@ -29,9 +29,8 @@ export type CronFormState = {
wakeMode: "next-heartbeat" | "now";
payloadKind: "systemEvent" | "agentTurn";
payloadText: string;
- deliver: boolean;
- channel: string;
- to: string;
+ deliveryMode: "none" | "announce";
+ deliveryChannel: string;
+ deliveryTo: string;
timeoutSeconds: string;
- postToMainPrefix: string;
};
diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts
index 0291a41e6c..8c36b59114 100644
--- a/ui/src/ui/views/chat.ts
+++ b/ui/src/ui/views/chat.ts
@@ -347,7 +347,7 @@ export function renderChat(props: ChatProps) {
props.showNewMessages
? html`