feat(trigger): add session-bound local triggers

This commit is contained in:
chengyongru
2026-07-02 13:32:46 +08:00
committed by Xubin Ren
parent c78421cf16
commit 2a0cd19a74
33 changed files with 1566 additions and 67 deletions
+6
View File
@@ -122,6 +122,9 @@ function formatAutomationSchedule(
})
: t("deleteConfirm.schedule.cron", { expr: job.schedule.expr });
}
if (job.schedule.kind === "external" || job.payload.kind === "external_trigger") {
return t("deleteConfirm.schedule.external", { defaultValue: "External trigger" });
}
return t("deleteConfirm.schedule.unknown");
}
@@ -131,6 +134,9 @@ function formatAutomationNextRun(
locale: string,
): string {
if (!job.enabled) return t("deleteConfirm.next.disabled");
if (job.schedule.kind === "external" || job.payload.kind === "external_trigger") {
return t("deleteConfirm.next.external", { defaultValue: "Waiting for trigger" });
}
const next = job.state.next_run_at_ms;
if (!next) return t("deleteConfirm.next.none");
return t("deleteConfirm.next.label", { time: fmtDateTime(next, locale) });
+1 -1
View File
@@ -167,7 +167,7 @@ export function MessageBubble({
const reasoning = message.role === "assistant" ? message.reasoning ?? "" : "";
const reasoningStreaming = !!(message.role === "assistant" && message.reasoningStreaming);
const hasReasoning = reasoning.length > 0 || reasoningStreaming;
const automationSourceLabel = message.source?.kind === "cron"
const automationSourceLabel = message.source?.kind === "cron" || message.source?.kind === "trigger"
? (message.source.label?.trim() || t("message.automationSourceFallback"))
: "";
const automationTriggeredLabel = t("message.automationTriggered");
+129 -43
View File
@@ -21,6 +21,7 @@ import {
ChevronLeft,
ChevronRight,
Cloud,
Clipboard,
Cpu,
Database,
Eye,
@@ -106,6 +107,7 @@ import {
updateWebSearchSettings,
} from "@/lib/api";
import { notifyCliAppsChanged } from "@/lib/cli-app-events";
import { copyTextToClipboard } from "@/lib/clipboard";
import { getHostApi } from "@/lib/runtime";
import { notifyMcpPresetsChanged } from "@/lib/mcp-preset-events";
import { fmtDateTime, relativeTime } from "@/lib/format";
@@ -3671,6 +3673,7 @@ function AutomationListItem({
const status = automationStatus(job, tx);
const origin = automationOriginLabel(job, tx);
const nextRun = formatAutomationNext(job, tx);
const summary = automationSummary(job, tx);
return (
<div role="listitem">
@@ -3696,7 +3699,7 @@ function AutomationListItem({
</span>
</span>
<span className="mt-1.5 line-clamp-2 text-[12px] leading-5 text-muted-foreground">
{job.payload.message || tx("settings.automations.systemTask", "System-managed automation")}
{summary}
</span>
<span className="mt-2.5 flex min-w-0 items-center gap-2 text-[11.5px] leading-none text-muted-foreground">
<span className="truncate" title={formatAutomationNextTitle(job, locale, tx)}>
@@ -3753,13 +3756,20 @@ function AutomationDetailPanel({
: null;
const created = job.created_at_ms ? fmtDateTime(job.created_at_ms, locale) : null;
const updated = job.updated_at_ms ? fmtDateTime(job.updated_at_ms, locale) : null;
const message = job.payload.message || tx("settings.automations.systemTask", "System-managed automation");
const externalTrigger = isExternalTriggerAutomation(job);
const triggerCommand = automationTriggerCommand(job);
const message = automationDetailText(job, tx);
const messageLabel = externalTrigger
? tx("settings.automations.fields.command", "Command")
: tx("settings.automations.fields.message", "Message");
const schedule = formatAutomationSchedule(job, locale, tx);
const [messageExpanded, setMessageExpanded] = useState(false);
const [commandCopied, setCommandCopied] = useState(false);
const messageNeedsExpansion = automationMessageNeedsExpansion(message);
useEffect(() => {
setMessageExpanded(false);
setCommandCopied(false);
}, [job.id]);
return (
@@ -3793,12 +3803,37 @@ function AutomationDetailPanel({
<div className="grid min-h-0 min-w-0 flex-1 overflow-hidden lg:grid-cols-[minmax(0,1fr)_14.5rem]">
<div className="min-h-0 min-w-0 space-y-3 overflow-y-auto overscroll-contain p-4 sm:p-5">
<section className="rounded-[20px] border border-border/35 bg-background/62 px-4 py-3.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.58)] dark:border-white/10 dark:bg-background/24">
<div className="text-[11px] font-medium leading-none text-muted-foreground/75">
{tx("settings.automations.fields.message", "Message")}
<div className="flex items-center justify-between gap-3">
<div className="text-[11px] font-medium leading-none text-muted-foreground/75">
{messageLabel}
</div>
{externalTrigger && triggerCommand ? (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 shrink-0 rounded-full px-2 text-[11.5px]"
onClick={() => {
void copyTextToClipboard(triggerCommand).then((ok) => {
if (ok) setCommandCopied(true);
});
}}
>
{commandCopied ? (
<Check className="mr-1.5 h-3.5 w-3.5" aria-hidden />
) : (
<Clipboard className="mr-1.5 h-3.5 w-3.5" aria-hidden />
)}
{commandCopied
? tx("settings.automations.commandCopied", "Copied")
: tx("settings.automations.copyCommand", "Copy")}
</Button>
) : null}
</div>
<div
className={cn(
"mt-3 whitespace-pre-wrap break-words text-[13px] leading-6 text-foreground/85",
externalTrigger && "font-mono text-[12.5px]",
!messageExpanded && messageNeedsExpansion && "line-clamp-6",
)}
>
@@ -3905,7 +3940,8 @@ function AutomationActionGroup({
t(key, { defaultValue: fallback, ...(values ?? {}) });
const canManage = !job.protected;
const hasLinkedChat = Boolean(job.origin);
const canRun = canManage && hasLinkedChat && job.enabled && !job.state.pending;
const externalTrigger = isExternalTriggerAutomation(job);
const canRun = canManage && hasLinkedChat && job.enabled && !job.state.pending && !externalTrigger;
const toggleAction: AutomationAction = job.enabled ? "disable" : "enable";
const canToggle = canManage && (job.enabled || hasLinkedChat);
const toggleBusy = actionKey === `${toggleAction}:${job.id}`;
@@ -3927,14 +3963,16 @@ function AutomationActionGroup({
>
<Pencil className="h-4 w-4" aria-hidden />
</AppsActionButton>
<AppsActionButton
ariaLabel={tx("settings.automations.runNow", "Run now")}
busy={actionKey === `run:${job.id}`}
disabled={!canRun}
onClick={() => void onAction("run", job)}
>
<PlayCircle className="h-4 w-4" aria-hidden />
</AppsActionButton>
{!externalTrigger ? (
<AppsActionButton
ariaLabel={tx("settings.automations.runNow", "Run now")}
busy={actionKey === `run:${job.id}`}
disabled={!canRun}
onClick={() => void onAction("run", job)}
>
<PlayCircle className="h-4 w-4" aria-hidden />
</AppsActionButton>
) : null}
<AppsActionButton
ariaLabel={
job.enabled
@@ -4057,6 +4095,7 @@ function AutomationEditDialog({
const tx = (key: string, fallback: string, values?: Record<string, unknown>) =>
t(key, { defaultValue: fallback, ...(values ?? {}) });
const [draft, setDraft] = useState<AutomationEditDraft>(() => automationDraftFromJob(null));
const externalTrigger = isExternalTriggerAutomation(job);
useEffect(() => {
setDraft(automationDraftFromJob(job));
@@ -4106,34 +4145,38 @@ function AutomationEditDialog({
/>
</label>
<label className="block space-y-1.5">
<span className="text-[12px] font-medium text-muted-foreground">
{tx("settings.automations.fields.message", "Message")}
</span>
<Textarea
value={draft.message}
onChange={(event) => setDraft((prev) => ({ ...prev, message: event.target.value }))}
className="min-h-[160px] resize-none rounded-[12px] text-[13px] leading-5"
/>
</label>
{!externalTrigger ? (
<label className="block space-y-1.5">
<span className="text-[12px] font-medium text-muted-foreground">
{tx("settings.automations.fields.message", "Message")}
</span>
<Textarea
value={draft.message}
onChange={(event) => setDraft((prev) => ({ ...prev, message: event.target.value }))}
className="min-h-[160px] resize-none rounded-[12px] text-[13px] leading-5"
/>
</label>
) : null}
<div className="space-y-2">
<span className="text-[12px] font-medium text-muted-foreground">
{tx("settings.automations.fields.scheduleType", "Schedule type")}
</span>
<SegmentedControl
value={draft.scheduleKind}
options={scheduleOptions}
onChange={(value) =>
setDraft((prev) => ({
...prev,
scheduleKind: value as AutomationEditDraft["scheduleKind"],
}))
}
/>
</div>
{!externalTrigger ? (
<div className="space-y-2">
<span className="text-[12px] font-medium text-muted-foreground">
{tx("settings.automations.fields.scheduleType", "Schedule type")}
</span>
<SegmentedControl
value={draft.scheduleKind}
options={scheduleOptions}
onChange={(value) =>
setDraft((prev) => ({
...prev,
scheduleKind: value as AutomationEditDraft["scheduleKind"],
}))
}
/>
</div>
) : null}
{draft.scheduleKind === "every" ? (
{!externalTrigger && draft.scheduleKind === "every" ? (
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_10rem]">
<label className="block space-y-1.5">
<span className="text-[12px] font-medium text-muted-foreground">
@@ -4174,7 +4217,7 @@ function AutomationEditDialog({
</div>
) : null}
{draft.scheduleKind === "cron" ? (
{!externalTrigger && draft.scheduleKind === "cron" ? (
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_12rem]">
<label className="block space-y-1.5">
<span className="text-[12px] font-medium text-muted-foreground">
@@ -4201,7 +4244,7 @@ function AutomationEditDialog({
</div>
) : null}
{draft.scheduleKind === "at" ? (
{!externalTrigger && draft.scheduleKind === "at" ? (
<label className="block space-y-1.5">
<span className="text-[12px] font-medium text-muted-foreground">
{tx("settings.automations.fields.runAt", "Run at")}
@@ -4266,7 +4309,7 @@ function AutomationDeleteDialog({
<DialogDescription>
{tx(
"settings.automations.deleteDescription",
"This removes {{name}} from the cron store. Past chat messages stay in the session.",
"This removes {{name}} from automations. Past chat messages stay in the session.",
{ name: job?.name || job?.id || "" },
)}
</DialogDescription>
@@ -4297,6 +4340,34 @@ function AutomationDeleteDialog({
);
}
function isExternalTriggerAutomation(job: SessionAutomationJob | null): boolean {
if (!job) return false;
return job.kind === "external_trigger"
|| job.payload.kind === "external_trigger"
|| job.schedule.kind === "external";
}
function automationTriggerCommand(job: SessionAutomationJob): string {
return job.trigger?.command || job.payload.command || job.payload.message || "";
}
function automationSummary(
job: SessionAutomationJob,
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
): string {
if (isExternalTriggerAutomation(job)) {
return automationTriggerCommand(job) || tx("settings.automations.externalTrigger", "External trigger");
}
return job.payload.message || tx("settings.automations.systemTask", "System-managed automation");
}
function automationDetailText(
job: SessionAutomationJob,
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
): string {
return automationSummary(job, tx);
}
function automationNeedsAttention(job: SessionAutomationJob): boolean {
return job.state.last_status === "error";
}
@@ -4308,6 +4379,7 @@ function automationStatusKey(
if (job.state.pending) return "running";
if (!job.enabled) return "paused";
if (job.state.last_status === "error") return "failed";
if (isExternalTriggerAutomation(job)) return "active";
if (job.delete_after_run && !job.state.next_run_at_ms && job.state.last_status === "ok") {
return "completed";
}
@@ -4371,6 +4443,7 @@ function automationEditDraftError(
tx: (key: string, fallback: string, values?: Record<string, unknown>) => string,
): string | null {
if (!draft.name.trim()) return tx("settings.automations.validation.nameRequired", "Name is required.");
if (isExternalTriggerAutomation(job)) return null;
if (!draft.message.trim()) {
return tx("settings.automations.validation.messageRequired", "Message is required.");
}
@@ -4400,6 +4473,10 @@ function automationUpdatePayloadFromDraft(
job: SessionAutomationJob | null,
): AutomationUpdatePayload | string {
const name = draft.name.trim();
if (isExternalTriggerAutomation(job)) {
if (!name) return "invalid";
return { name };
}
const message = draft.message.trim();
if (!name || !message) return "invalid";
const payload: AutomationUpdatePayload = { name, message };
@@ -4517,7 +4594,7 @@ function automationSearchParts(
const scheduleParts = automationScheduleSearchParts(job);
if (field === "id") return [job.id];
if (field === "name") return [job.name, job.id];
if (field === "message") return [job.payload.message];
if (field === "message") return [job.payload.message, job.payload.command, job.trigger?.command];
if (field === "chat") return originParts;
if (field === "cron" || field === "schedule") return scheduleParts;
if (field === "status") return [automationStatusKey(job), job.enabled ? "enabled" : "disabled"];
@@ -4525,6 +4602,9 @@ function automationSearchParts(
job.id,
job.name,
job.payload.message,
job.payload.command,
job.trigger?.command,
isExternalTriggerAutomation(job) ? "trigger external" : null,
...scheduleParts,
automationStatusKey(job),
...originParts,
@@ -4714,6 +4794,9 @@ function formatAutomationSchedule(
})
: tx("settings.automations.schedule.cron", "Cron {{expr}}", { expr: job.schedule.expr });
}
if (isExternalTriggerAutomation(job)) {
return tx("settings.automations.schedule.external", "External trigger");
}
return tx("settings.automations.schedule.custom", "Custom schedule");
}
@@ -4768,6 +4851,9 @@ function formatAutomationNext(
): string {
if (!job.enabled) return tx("settings.automations.next.paused", "Paused");
if (job.state.pending) return tx("settings.automations.next.pending", "Running now");
if (isExternalTriggerAutomation(job)) {
return tx("settings.automations.next.external", "Waiting for trigger");
}
if (!job.state.next_run_at_ms) return tx("settings.automations.next.none", "No next run");
return relativeTime(job.state.next_run_at_ms);
}