+
+
+
+ {popover()}
+
+ {popover()}
+
+
+
{
+ scrollRef = el
+ props.scrollRef?.(el)
+ }}
+ >
+
{
+ editorRef = el
+ props.ref?.(el)
+ }}
+ role="textbox"
+ aria-multiline="true"
+ aria-label={props.placeholder}
+ contenteditable="true"
+ onInput={handleInput}
+ onPaste={handlePaste}
+ onCompositionStart={() => setComposing(true)}
+ onCompositionEnd={() => setComposing(false)}
+ onKeyDown={handleKeyDown}
+ classList={{
+ "select-text": true,
+ "w-full p-3 pr-12 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
+ "[&_[data-type=file]]:text-syntax-property": true,
+ "[&_[data-type=agent]]:text-syntax-type": true,
+ "font-mono!": mode() === "shell",
+ [props.editorClass ?? ""]: !!props.editorClass,
+ }}
+ />
+
+
+ {props.placeholder}
+
+
+
+
+ )
+}
+
+function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
+ if (promptA.length !== promptB.length) return false
+ for (let i = 0; i < promptA.length; i++) {
+ const partA = promptA[i]
+ const partB = promptB[i]
+ if (partA.type !== partB.type) return false
+ if (partA.type === "text" && partA.content !== (partB as { content: string }).content) return false
+ if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) return false
+ if (partA.type === "agent" && partA.name !== (partB as AgentPart).name) return false
+ }
+ return true
+}
+
+export function createTextFragment(content: string): DocumentFragment {
+ const fragment = document.createDocumentFragment()
+ const segments = content.split("\n")
+ segments.forEach((segment, index) => {
+ if (segment) {
+ fragment.appendChild(document.createTextNode(segment))
+ }
+ if (!segment && segments.length > 1) {
+ fragment.appendChild(document.createTextNode("\u200B"))
+ }
+ if (index < segments.length - 1) {
+ fragment.appendChild(document.createElement("br"))
+ }
+ })
+ return fragment
+}
+
+function getNodeLength(node: Node): number {
+ if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1
+ return (node.textContent ?? "").replace(/\u200B/g, "").length
+}
+
+function getTextLength(node: Node): number {
+ if (node.nodeType === Node.TEXT_NODE) return (node.textContent ?? "").replace(/\u200B/g, "").length
+ if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1
+ let length = 0
+ for (const child of Array.from(node.childNodes)) {
+ length += getTextLength(child)
+ }
+ return length
+}
+
+export function getCursorPosition(parent: HTMLElement): number {
+ const selection = window.getSelection()
+ if (!selection || selection.rangeCount === 0) return 0
+ const range = selection.getRangeAt(0)
+ if (!parent.contains(range.startContainer)) return 0
+ const preCaretRange = range.cloneRange()
+ preCaretRange.selectNodeContents(parent)
+ preCaretRange.setEnd(range.startContainer, range.startOffset)
+ return getTextLength(preCaretRange.cloneContents())
+}
+
+export function setCursorPosition(parent: HTMLElement, position: number) {
+ let remaining = position
+ let node = parent.firstChild
+ while (node) {
+ const length = getNodeLength(node)
+ const isText = node.nodeType === Node.TEXT_NODE
+ const isPill =
+ node.nodeType === Node.ELEMENT_NODE &&
+ ((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent")
+ const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
+
+ if (isText && remaining <= length) {
+ const range = document.createRange()
+ const selection = window.getSelection()
+ range.setStart(node, remaining)
+ range.collapse(true)
+ selection?.removeAllRanges()
+ selection?.addRange(range)
+ return
+ }
+
+ if ((isPill || isBreak) && remaining <= length) {
+ const range = document.createRange()
+ const selection = window.getSelection()
+ if (remaining === 0) {
+ range.setStartBefore(node)
+ }
+ if (remaining > 0 && isPill) {
+ range.setStartAfter(node)
+ }
+ if (remaining > 0 && isBreak) {
+ const next = node.nextSibling
+ if (next && next.nodeType === Node.TEXT_NODE) {
+ range.setStart(next, 0)
+ }
+ if (!next || next.nodeType !== Node.TEXT_NODE) {
+ range.setStartAfter(node)
+ }
+ }
+ range.collapse(true)
+ selection?.removeAllRanges()
+ selection?.addRange(range)
+ return
+ }
+
+ remaining -= length
+ node = node.nextSibling
+ }
+
+ const fallbackRange = document.createRange()
+ const fallbackSelection = window.getSelection()
+ const last = parent.lastChild
+ if (last && last.nodeType === Node.TEXT_NODE) {
+ const len = last.textContent ? last.textContent.length : 0
+ fallbackRange.setStart(last, len)
+ }
+ if (!last || last.nodeType !== Node.TEXT_NODE) {
+ fallbackRange.selectNodeContents(parent)
+ }
+ fallbackRange.collapse(false)
+ fallbackSelection?.removeAllRanges()
+ fallbackSelection?.addRange(fallbackRange)
+}
diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx
index e2bf4498074..01288c4e57b 100644
--- a/packages/app/src/context/global-sync.tsx
+++ b/packages/app/src/context/global-sync.tsx
@@ -1,4 +1,5 @@
import {
+ type Automation,
type Config,
type Path,
type Project,
@@ -33,6 +34,7 @@ import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap"
import { sanitizeProject } from "./global-sync/utils"
+import { Binary } from "@opencode-ai/util/binary"
import type { ProjectMeta } from "./global-sync/types"
import { SESSION_RECENT_LIMIT } from "./global-sync/types"
@@ -41,6 +43,7 @@ type GlobalStore = {
error?: InitError
path: Path
project: Project[]
+ automation: Automation[]
provider: ProviderListResponse
provider_auth: ProviderAuthResponse
config: Config
@@ -73,6 +76,7 @@ function createGlobalSync() {
ready: false,
path: { state: "", config: "", worktree: "", directory: "", home: "" },
project: projectCache.value,
+ automation: [],
provider: { all: [], connected: [], default: {} },
provider_auth: {},
config: {},
@@ -247,11 +251,20 @@ function createGlobalSync() {
const unsub = globalSDK.event.listen((e) => {
const directory = e.name
const event = e.details
+ if (!event) return
if (directory === "global") {
applyGlobalEvent({
event,
project: globalStore.project,
+ automations: globalStore.automation,
+ setAutomations(next) {
+ if (typeof next === "function") {
+ setGlobalStore("automation", produce(next))
+ return
+ }
+ setGlobalStore("automation", next)
+ },
refresh: queue.refresh,
setGlobalProject(next) {
if (typeof next === "function") {
diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts
index 2137a19a823..392da043bc2 100644
--- a/packages/app/src/context/global-sync/bootstrap.ts
+++ b/packages/app/src/context/global-sync/bootstrap.ts
@@ -14,12 +14,14 @@ import { retry } from "@opencode-ai/util/retry"
import { getFilename } from "@opencode-ai/util/path"
import { showToast } from "@opencode-ai/ui/toast"
import { cmp, normalizeProviderList } from "./utils"
+import type { Automation } from "@opencode-ai/sdk/v2/client"
import type { State, VcsCache } from "./types"
type GlobalStore = {
ready: boolean
path: Path
project: Project[]
+ automation: Automation[]
provider: ProviderListResponse
provider_auth: ProviderAuthResponse
config: Config
@@ -68,6 +70,14 @@ export async function bootstrapGlobal(input: {
input.setGlobalStore("project", projects)
}),
),
+ retry(() =>
+ input.globalSDK.automation.list().then((x) => {
+ input.setGlobalStore(
+ "automation",
+ (x.data ?? []).slice().sort((a, b) => cmp(a.id, b.id)),
+ )
+ }),
+ ),
retry(() =>
input.globalSDK.provider.list().then((x) => {
input.setGlobalStore("provider", normalizeProviderList(x.data!))
diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts
index c658d82c8b7..da77224b749 100644
--- a/packages/app/src/context/global-sync/event-reducer.ts
+++ b/packages/app/src/context/global-sync/event-reducer.ts
@@ -1,6 +1,6 @@
-import { Binary } from "@opencode-ai/util/binary"
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import type {
+ Automation,
FileDiff,
Message,
Part,
@@ -11,12 +11,15 @@ import type {
SessionStatus,
Todo,
} from "@opencode-ai/sdk/v2/client"
+import { Binary } from "@opencode-ai/util/binary"
import type { State, VcsCache } from "./types"
import { trimSessions } from "./session-trim"
export function applyGlobalEvent(input: {
event: { type: string; properties?: unknown }
project: Project[]
+ automations?: Automation[]
+ setAutomations?: (next: Automation[] | ((draft: Automation[]) => void)) => void
setGlobalProject: (next: Project[] | ((draft: Project[]) => void)) => void
refresh: () => void
}) {
@@ -25,6 +28,57 @@ export function applyGlobalEvent(input: {
return
}
+ if (input.event.type === "automation.created" || input.event.type === "automation.updated") {
+ if (!input.automations || !input.setAutomations) return
+ const properties = input.event.properties as Automation
+ const result = Binary.search(input.automations, properties.id, (s) => s.id)
+ if (result.found) {
+ input.setAutomations((draft) => {
+ draft[result.index] = { ...draft[result.index], ...properties }
+ })
+ return
+ }
+ input.setAutomations((draft) => {
+ draft.splice(result.index, 0, properties)
+ })
+ return
+ }
+
+ if (input.event.type === "automation.deleted") {
+ if (!input.automations || !input.setAutomations) return
+ const properties = input.event.properties as Automation
+ const result = Binary.search(input.automations, properties.id, (s) => s.id)
+ if (!result.found) return
+ input.setAutomations((draft) => {
+ draft.splice(result.index, 1)
+ })
+ return
+ }
+
+ if (input.event.type === "automation.run") {
+ if (!input.automations || !input.setAutomations) return
+ const properties = input.event.properties as {
+ automationID: string
+ time: number
+ status: "success" | "failed"
+ sessionID?: string
+ directory: string
+ }
+ const result = Binary.search(input.automations, properties.automationID, (s) => s.id)
+ if (!result.found) return
+ input.setAutomations((draft) => {
+ draft[result.index] = {
+ ...draft[result.index],
+ lastRun: properties.time,
+ lastSession:
+ properties.status === "success" && properties.sessionID
+ ? { id: properties.sessionID, directory: properties.directory }
+ : draft[result.index]?.lastSession,
+ }
+ })
+ return
+ }
+
if (input.event.type !== "project.updated") return
const properties = input.event.properties as Project
const result = Binary.search(input.project, properties.id, (s) => s.id)
diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts
index 8fba6861b0b..1d18959cf38 100644
--- a/packages/app/src/i18n/en.ts
+++ b/packages/app/src/i18n/en.ts
@@ -26,6 +26,7 @@ export const dict = {
"command.provider.connect": "Connect provider",
"command.server.switch": "Switch server",
"command.settings.open": "Open settings",
+ "command.automations.open": "Open automations",
"command.session.previous": "Previous session",
"command.session.next": "Next session",
"command.session.previous.unseen": "Previous unread session",
@@ -568,6 +569,7 @@ export const dict = {
"sidebar.menu.toggle": "Toggle menu",
"sidebar.nav.projectsAndSessions": "Projects and sessions",
"sidebar.settings": "Settings",
+ "sidebar.automations": "Automations",
"sidebar.help": "Help",
"sidebar.workspaces.enable": "Enable workspaces",
"sidebar.workspaces.disable": "Disable workspaces",
@@ -577,6 +579,98 @@ export const dict = {
"sidebar.project.recentSessions": "Recent sessions",
"sidebar.project.viewAllSessions": "View all sessions",
+ "session.automation.badge": "Automated run",
+ "session.automation.prefix": "Automation:",
+
+ "automations.title": "Automations",
+ "automations.description": "Schedule or manually run prompts across projects.",
+ "automations.create.button": "Create automation",
+ "automations.edit.title": "Edit automation",
+ "automations.create.title": "New automation",
+ "automations.empty.title": "No automations yet",
+ "automations.empty.description": "Create one to run scheduled prompts across projects.",
+ "automations.table.name": "Name",
+ "automations.table.schedule": "Schedule",
+ "automations.table.next": "Next run",
+ "automations.table.last": "Last run",
+ "automations.badge.disabled": "Disabled",
+ "automations.action.run": "Run",
+ "automations.run.started": "Automation run started",
+ "automations.action.edit": "Edit",
+ "automations.action.delete": "Delete",
+ "automations.action.export": "Export",
+ "automations.action.openSession": "Open session",
+ "automations.action.history": "History",
+ "automations.export.all": "Export all",
+ "automations.export.empty": "No automations to export",
+ "automations.import.action": "Import",
+ "automations.import.success": "Imported {count} automations",
+ "automations.import.failed": "Failed to import automations",
+ "automations.delete.title": "Delete automation",
+ "automations.delete.confirm": "Delete {{name}}?",
+ "automations.delete.button": "Delete automation",
+ "automations.time.never": "Never",
+ "automations.time.unknown": "Unknown",
+ "automations.schedule.manual": "Manual only",
+ "automations.schedule.disabled": "Disabled - {{schedule}}",
+ "automations.schedule.multi": "Multiple - {{count}}",
+ "automations.history.title": "Run history",
+ "automations.history.empty": "No runs yet.",
+ "automations.history.table.time": "Time",
+ "automations.history.table.project": "Project",
+ "automations.history.table.status": "Status",
+ "automations.history.table.session": "Session",
+ "automations.history.status.success": "Success",
+ "automations.history.status.failed": "Failed",
+ "automations.history.clear.action": "Clear history",
+ "automations.history.clear.title": "Clear run history",
+ "automations.history.clear.confirm": "Clear run history for all automations?",
+ "automations.history.clear.button": "Clear history",
+ "automations.template.date.label": "Date",
+ "automations.template.date.description": "Current date",
+ "automations.template.project.label": "Project name",
+ "automations.template.project.description": "Automation project name",
+ "automations.template.sessionLatest.label": "Latest session",
+ "automations.template.sessionLatest.description": "Latest session ID for this project",
+ "automations.template.sessionQuery.label": "Session by query",
+ "automations.template.sessionQuery.description": "Latest session that matches the query",
+
+ "automations.form.name": "Name",
+ "automations.form.name.placeholder": "Daily summary",
+ "automations.form.projects": "Projects",
+ "automations.form.projects.count": "{{count}} selected",
+ "automations.form.projects.empty": "No projects available.",
+ "automations.form.prompt": "Prompt",
+ "automations.form.prompt.description": "Supports slash commands, skills, @file references, and {{ template }} variables.",
+ "automations.form.prompt.placeholder": "Summarize today's progress and open PRs.",
+ "automations.form.schedule": "Schedule",
+ "automations.form.schedule.hint": "Cron uses local server time.",
+ "automations.form.schedule.enabled": "Enable schedule",
+ "automations.form.schedule.mode": "Schedule type",
+ "automations.form.schedule.mode.picker": "Picker",
+ "automations.form.schedule.mode.cron": "Cron",
+ "automations.form.schedule.invalid": "Add at least one day and one time to enable scheduling.",
+ "automations.form.schedule.invalidCron": "Invalid cron: {{error}}",
+ "automations.form.schedule.calculating": "Calculating next run...",
+ "automations.form.schedule.nextRun": "Next run",
+ "automations.form.presets": "Presets",
+ "automations.form.presets.weekdays": "Weekdays 9:00",
+ "automations.form.presets.daily": "Daily 9:00",
+ "automations.form.days": "Days",
+ "automations.form.times": "Times",
+ "automations.form.times.add": "Add time",
+ "automations.form.times.empty": "No times added yet.",
+ "automations.form.times.remove": "Remove time",
+ "automations.form.cron.preview": "Generated cron",
+ "automations.form.cron.expression": "Cron expression",
+ "automations.day.mon": "Mon",
+ "automations.day.tue": "Tue",
+ "automations.day.wed": "Wed",
+ "automations.day.thu": "Thu",
+ "automations.day.fri": "Fri",
+ "automations.day.sat": "Sat",
+ "automations.day.sun": "Sun",
+
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "Desktop",
diff --git a/packages/app/src/pages/automations.tsx b/packages/app/src/pages/automations.tsx
new file mode 100644
index 00000000000..c0b3e8dfe60
--- /dev/null
+++ b/packages/app/src/pages/automations.tsx
@@ -0,0 +1,445 @@
+import { Button } from "@opencode-ai/ui/button"
+import { Dialog } from "@opencode-ai/ui/dialog"
+import { Icon } from "@opencode-ai/ui/icon"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { DateTime } from "luxon"
+import { createMemo, For, onMount, Show } from "solid-js"
+import { createStore } from "solid-js/store"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { showToast } from "@opencode-ai/ui/toast"
+import { useGlobalSDK } from "@/context/global-sdk"
+import { useGlobalSync } from "@/context/global-sync"
+import { useLanguage } from "@/context/language"
+import { DialogAutomation } from "@/components/dialog-automation"
+import { DialogAutomationDelete } from "@/components/dialog-automation-delete"
+import { DialogConfirm } from "@/components/dialog-confirm"
+import { AutomationTransfer } from "@opencode-ai/util/automation-transfer"
+import { base64Encode } from "@opencode-ai/util/encode"
+import { getFilename } from "@opencode-ai/util/path"
+import { slugify } from "@opencode-ai/util/slugify"
+import { useNavigate } from "@solidjs/router"
+import type { Automation, AutomationRun } from "@opencode-ai/sdk/v2/client"
+
+export default function AutomationsPage() {
+ const dialog = useDialog()
+ const globalSDK = useGlobalSDK()
+ const globalSync = useGlobalSync()
+ const language = useLanguage()
+ const navigate = useNavigate()
+ let importRef!: HTMLInputElement
+
+ const automations = createMemo(() =>
+ (globalSync.data.automation ?? [])
+ .filter((item): item is Automation => !!item && typeof item === "object")
+ .slice()
+ .sort((a, b) => String(a.name ?? "").localeCompare(String(b.name ?? ""))),
+ )
+
+ const formatTime = (value?: number) => {
+ if (!value) return language.t("automations.time.never")
+
+ const time = DateTime.fromMillis(value)
+ return time.toRelative() ?? time.toLocaleString(DateTime.DATETIME_SHORT)
+ }
+
+ const projectLabel = (directory: string) => {
+ const project = globalSync.data.project.find((item) => item.worktree === directory)
+ if (project?.name) return project.name
+
+ return getFilename(directory)
+ }
+
+ const openSession = (session?: { id: string; directory: string }) => {
+ if (!session) return
+ navigate(`/${base64Encode(session.directory)}/session/${session.id}`)
+ }
+
+ const projectList = (automation: Automation) => {
+ const projects = automation.projects ?? []
+ const names = projects
+ .filter((directory) => directory && directory !== "/")
+ .map((directory) => projectLabel(directory))
+ return names.join(", ")
+ }
+
+ const scheduleLabel = (automation: Automation) => {
+ if (!automation.schedule) return language.t("automations.schedule.manual")
+
+ const lines = automation.schedule
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .filter(Boolean)
+
+ if (!automation.enabled) {
+ const schedule = lines[0] ?? automation.schedule
+ return language.t("automations.schedule.disabled", { schedule })
+ }
+ if (lines.length > 1) return language.t("automations.schedule.multi", { count: lines.length })
+ return lines[0] ?? automation.schedule
+ }
+
+ const openCreate = () => {
+ dialog.show(() =>
)
+ }
+
+ const openEdit = (automation: Automation) => {
+ dialog.show(() =>
)
+ }
+
+ const runAutomation = async (automation: Automation) => {
+ const result = await globalSDK.client.automation.run({ automationID: automation.id }).catch(() => undefined)
+ if (!result?.data) {
+ showToast({ title: language.t("common.requestFailed") })
+ return
+ }
+
+ const index = (globalSync.data.automation ?? []).findIndex((item) => item.id === result.data?.id)
+ if (index >= 0) globalSync.set("automation", index, result.data)
+
+ showToast({ title: language.t("automations.run.started") })
+ }
+
+ const deleteAutomation = async (automation: Automation) => {
+ await globalSDK.client.automation.remove({ automationID: automation.id })
+ }
+
+ const clearHistory = async () => {
+ await globalSDK.client.automation.clearHistory()
+ }
+
+ const downloadExport = (items: Automation[], filename: string) => {
+ if (items.length === 0) {
+ showToast({ title: language.t("automations.export.empty") })
+ return
+ }
+
+ const payload = AutomationTransfer.serialize(
+ items.map((item) => ({
+ name: item.name,
+ projects: item.projects,
+ prompt: item.prompt,
+ schedule: item.schedule ?? null,
+ enabled: item.enabled,
+ })),
+ )
+ const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" })
+ const url = URL.createObjectURL(blob)
+ const link = document.createElement("a")
+ link.href = url
+ link.download = filename
+ link.click()
+ link.remove()
+ URL.revokeObjectURL(url)
+ }
+
+ const exportAll = () => {
+ downloadExport(automations(), "automations.json")
+ }
+
+ const exportAutomation = (automation: Automation) => {
+ const name = slugify(String(automation.name ?? ""))
+ const suffix = name || automation.id.slice(-8)
+ downloadExport([automation], `automation-${suffix}.json`)
+ }
+
+ const handleImport = async (event: Event & { currentTarget: HTMLInputElement }) => {
+ const input = event.currentTarget
+ const file = input.files?.[0]
+ input.value = ""
+ if (!file) return
+
+ const data = await file
+ .text()
+ .then((text) => JSON.parse(text))
+ .catch(() => undefined)
+ if (!data) {
+ showToast({ title: language.t("automations.import.failed") })
+ return
+ }
+
+ const items = AutomationTransfer.parse(data)
+ if (items.length === 0) {
+ showToast({ title: language.t("automations.import.failed") })
+ return
+ }
+
+ const results = await Promise.all(
+ items.map((item) =>
+ globalSDK.client.automation
+ .create({
+ name: item.name,
+ prompt: item.prompt,
+ projects: item.projects,
+ schedule: item.schedule ?? null,
+ enabled: item.enabled,
+ })
+ .then(() => true)
+ .catch(() => false),
+ ),
+ )
+
+ const success = results.filter(Boolean).length
+ if (success === 0) {
+ showToast({ title: language.t("automations.import.failed") })
+ return
+ }
+ const list = await globalSDK.client.automation.list().catch(() => undefined)
+ if (list?.data) {
+ globalSync.set(
+ "automation",
+ list.data.slice().sort((a, b) => String(a.id).localeCompare(String(b.id))),
+ )
+ }
+ showToast({ title: language.t("automations.import.success", { count: success }) })
+ }
+
+ const openClearHistory = () => {
+ dialog.show(() => (
+
+ ))
+ }
+
+ function DialogAutomationHistory(props: { automation: Automation }) {
+ const [state, setState] = createStore({
+ loading: true,
+ error: "",
+ runs: [] as AutomationRun[],
+ })
+ const runs = createMemo(() => (Array.isArray(state.runs) ? state.runs : []))
+
+ onMount(() => {
+ globalSDK.client.automation
+ .history({ automationID: props.automation.id, limit: 25 })
+ .then((result) => {
+ const data = Array.isArray(result.data) ? result.data : []
+ setState({ loading: false, error: "", runs: data })
+ })
+ .catch((error) => {
+ const message = error instanceof Error ? error.message : String(error)
+ setState({ loading: false, error: message, runs: [] })
+ })
+ })
+
+ const formatStatus = (run: AutomationRun) =>
+ run.status === "success"
+ ? language.t("automations.history.status.success")
+ : language.t("automations.history.status.failed")
+
+ const formatRunTime = (value?: number) => {
+ if (!value || !Number.isFinite(value)) return language.t("automations.time.unknown")
+
+ const time = DateTime.fromMillis(value)
+ return time.toLocaleString(DateTime.DATETIME_SHORT)
+ }
+
+ return (
+
+
+ )
+ }
+
+ const openDelete = (automation: Automation) => {
+ dialog.show(() =>