diff --git a/bun.lock b/bun.lock
index 8d2fe82d78f..d1cffc182cb 100644
--- a/bun.lock
+++ b/bun.lock
@@ -315,6 +315,7 @@
"bun-pty": "0.4.8",
"chokidar": "4.0.3",
"clipboardy": "4.0.0",
+ "cron-parser": "4.9.0",
"decimal.js": "10.5.0",
"diff": "catalog:",
"fuzzysort": "3.1.0",
@@ -2271,6 +2272,8 @@
"crc32-stream": ["crc32-stream@6.0.0", "", { "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^4.0.0" } }, "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g=="],
+ "cron-parser": ["cron-parser@4.9.0", "", { "dependencies": { "luxon": "^3.2.1" } }, "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q=="],
+
"cross-fetch": ["cross-fetch@3.2.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx
index 5bbe86e2093..f301ff3d657 100644
--- a/packages/app/src/app.tsx
+++ b/packages/app/src/app.tsx
@@ -34,6 +34,7 @@ import { Suspense, JSX } from "solid-js"
const Home = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session"))
+const Automations = lazy(() => import("@/pages/automations"))
const Loading = () =>
function UiI18nBridge(props: ParentProps) {
@@ -140,6 +141,14 @@ export function AppInterface(props: { defaultUrl?: string; children?: JSX.Elemen
)}
/>
+ (
+ }>
+
+
+ )}
+ />
} />
void
+}
+
+export const AutomationDayToggle: Component = (props) => {
+ const active = () => props.active
+ const short = () => {
+ return props.label.slice(0, 2)
+ }
+
+ const handleClick = () => {
+ props.onChange(!active())
+ }
+
+ return (
+
+ {short()}
+
+ )
+}
diff --git a/packages/app/src/components/automation-time-row.tsx b/packages/app/src/components/automation-time-row.tsx
new file mode 100644
index 00000000000..c36fbe53001
--- /dev/null
+++ b/packages/app/src/components/automation-time-row.tsx
@@ -0,0 +1,80 @@
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { Select } from "@opencode-ai/ui/select"
+import { createMemo, type Component } from "solid-js"
+
+interface TimeRowProps {
+ index: number
+ value: string
+ hours: string[]
+ minutes: string[]
+ removeLabel: string
+ onChange: (value: string) => void
+ onRemove: () => void
+}
+
+export const AutomationTimeRow: Component = (props) => {
+ const parts = createMemo(() => props.value.split(":"))
+ const hour = () => {
+ const value = parts()[0] ?? "00"
+ return value.padStart(2, "0")
+ }
+ const minute = () => {
+ const value = parts()[1] ?? "00"
+ return value.padStart(2, "0")
+ }
+
+ const setHour = (value?: string) => {
+ if (!value) return
+
+ props.onChange(`${value}:${minute()}`)
+ }
+
+ const setMinute = (value?: string) => {
+ if (!value) return
+
+ props.onChange(`${hour()}:${value}`)
+ }
+
+ return (
+
+
+ {props.index + 1}
+
+
+ value}
+ label={(value) => value}
+ onSelect={setHour}
+ size="small"
+ variant="secondary"
+ valueClass="font-mono text-12-medium"
+ triggerStyle={{ "min-width": "72px" }}
+ portal={false}
+ />
+ :
+ value}
+ label={(value) => value}
+ onSelect={setMinute}
+ size="small"
+ variant="secondary"
+ valueClass="font-mono text-12-medium"
+ triggerStyle={{ "min-width": "72px" }}
+ portal={false}
+ />
+
+
+
+ )
+}
diff --git a/packages/app/src/components/dialog-automation-delete.tsx b/packages/app/src/components/dialog-automation-delete.tsx
new file mode 100644
index 00000000000..7a40ba9b6fb
--- /dev/null
+++ b/packages/app/src/components/dialog-automation-delete.tsx
@@ -0,0 +1,22 @@
+import { DialogConfirm } from "@/components/dialog-confirm"
+import { useLanguage } from "@/context/language"
+import type { Automation } from "@opencode-ai/sdk/v2/client"
+
+export function DialogAutomationDelete(props: {
+ automation: Automation
+ onDelete: (automation: Automation) => Promise
+}) {
+ const language = useLanguage()
+
+ const handleDelete = () => props.onDelete(props.automation)
+
+ return (
+
+ )
+}
diff --git a/packages/app/src/components/dialog-automation.tsx b/packages/app/src/components/dialog-automation.tsx
new file mode 100644
index 00000000000..23cc45cb45f
--- /dev/null
+++ b/packages/app/src/components/dialog-automation.tsx
@@ -0,0 +1,760 @@
+import { Button } from "@opencode-ai/ui/button"
+import { Checkbox } from "@opencode-ai/ui/checkbox"
+import { Dialog } from "@opencode-ai/ui/dialog"
+import { RadioGroup } from "@opencode-ai/ui/radio-group"
+import { Switch } from "@opencode-ai/ui/switch"
+import { TextField } from "@opencode-ai/ui/text-field"
+import { DateTime } from "luxon"
+import { createEffect, createMemo, createResource, For, on, onCleanup, Show } from "solid-js"
+import { createStore } from "solid-js/store"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { useLanguage } from "@/context/language"
+import { useGlobalSDK } from "@/context/global-sdk"
+import { useGlobalSync } from "@/context/global-sync"
+import { usePlatform } from "@/context/platform"
+import { useCommand } from "@/context/command"
+import { AutomationDayToggle } from "@/components/automation-day-toggle"
+import { AutomationTimeRow } from "@/components/automation-time-row"
+import { DialogAutomationDelete } from "@/components/dialog-automation-delete"
+import { PromptEditor, promptText, type SlashCommand, type TemplateOption } from "@/components/prompt-editor"
+import { DEFAULT_PROMPT, type Prompt } from "@/context/prompt"
+import { getFilename } from "@opencode-ai/util/path"
+import { createOpencodeClient, type Automation, type Project } from "@opencode-ai/sdk/v2/client"
+
+const dayOrder = ["1", "2", "3", "4", "5", "6", "0"]
+const defaultDays = ["1", "2", "3", "4", "5"]
+const defaultTimes = ["09:00"]
+const hours = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, "0"))
+const minutes = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, "0"))
+
+function sortDays(input: string[]) {
+ return dayOrder.filter((day) => input.includes(day))
+}
+
+function parseDays(value: string) {
+ if (value === "*") return dayOrder
+
+ const parts = value
+ .split(",")
+ .map((part) => part.trim())
+ .filter(Boolean)
+ if (parts.length === 0) return
+ if (parts.includes("*")) return dayOrder
+
+ const values: string[] = []
+ for (const part of parts) {
+ if (/^[0-6]$/.test(part)) {
+ values.push(part)
+ continue
+ }
+
+ if (!/^[0-6]-[0-6]$/.test(part)) continue
+
+ const start = Number(part[0])
+ const end = Number(part[2])
+ if (start > end) continue
+
+ const items = Array.from({ length: end - start + 1 }, (_, i) => String(start + i))
+ values.push(...items)
+ }
+
+ if (values.length === 0) return
+ if (values.some((item) => !/^[0-6]$/.test(item))) return
+
+ return sortDays([...new Set(values)])
+}
+
+function parseTime(hour: string, minute: string) {
+ if (!/^\d+$/.test(hour) || !/^\d+$/.test(minute)) return
+
+ const h = Number(hour)
+ const m = Number(minute)
+
+ if (!Number.isFinite(h) || !Number.isFinite(m)) return
+ if (h < 0 || h > 23) return
+ if (m < 0 || m > 59) return
+
+ return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`
+}
+
+function promptParts(value: string) {
+ if (!value) return DEFAULT_PROMPT
+
+ const parts: Prompt = []
+ const regex = /@\S+/g
+ let cursor = 0
+ let position = 0
+
+ const pushText = (content: string) => {
+ if (!content) return
+ parts.push({
+ type: "text",
+ content,
+ start: position,
+ end: position + content.length,
+ })
+ position += content.length
+ }
+
+ for (const match of value.matchAll(regex)) {
+ const mention = match[0]
+ const start = match.index ?? cursor
+ const previous = start > 0 ? value[start - 1] : ""
+ const separated = start === 0 || /\s/.test(previous)
+ if (!separated) continue
+
+ pushText(value.slice(cursor, start))
+
+ const path = mention.slice(1)
+ if (!path) {
+ pushText(mention)
+ cursor = start + mention.length
+ continue
+ }
+
+ parts.push({
+ type: "file",
+ path,
+ content: mention,
+ start: position,
+ end: position + mention.length,
+ })
+ position += mention.length
+ cursor = start + mention.length
+ }
+
+ pushText(value.slice(cursor))
+ if (parts.length === 0) {
+ return [{ type: "text", content: value, start: 0, end: value.length }] satisfies Prompt
+ }
+
+ return parts
+}
+
+function parseBuilderSchedule(value: string | null | undefined) {
+ const raw = value?.trim()
+ if (!raw) return
+
+ const lines = raw
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .filter(Boolean)
+ if (lines.length === 0) return
+
+ const times: string[] = []
+ let days: string[] | undefined
+
+ for (const line of lines) {
+ const parts = line.split(/\s+/)
+ if (parts.length !== 5) return
+
+ const minute = parts[0]
+ const hour = parts[1]
+ const day = parts[2]
+ const month = parts[3]
+ const week = parts[4]
+ if (day !== "*" || month !== "*") return
+
+ const parsedDays = parseDays(week)
+ if (!parsedDays) return
+
+ const time = parseTime(hour, minute)
+ if (!time) return
+
+ if (days && days.join(",") !== parsedDays.join(",")) return
+ if (!days) days = parsedDays
+
+ times.push(time)
+ }
+
+ return {
+ days: days ?? dayOrder,
+ times: [...new Set(times)].sort(),
+ }
+}
+
+function buildSchedule(days: string[], times: string[]) {
+ const normalizedDays = sortDays(days)
+ const normalizedTimes = [...new Set(times)].filter(Boolean)
+
+ if (normalizedDays.length === 0) return ""
+ if (normalizedTimes.length === 0) return ""
+
+ const dayList = normalizedDays.join(",")
+ const lines = normalizedTimes
+ .map((time) => {
+ const parts = time.split(":")
+ const hour = parts[0]
+ const minute = parts[1]
+ if (!hour || !minute) return undefined
+
+ const h = String(Number(hour)).padStart(2, "0")
+ const m = String(Number(minute)).padStart(2, "0")
+ if (!parseTime(h, m)) return undefined
+
+ return `${m} ${h} * * ${dayList}`
+ })
+ .filter((line): line is string => !!line)
+
+ return lines.join("\n")
+}
+
+function projectLabel(project: Project) {
+ return project.name || getFilename(project.worktree)
+}
+
+export function DialogAutomation(props: { automation?: Automation }) {
+ const dialog = useDialog()
+ const language = useLanguage()
+ const globalSDK = useGlobalSDK()
+ const globalSync = useGlobalSync()
+ const platform = usePlatform()
+ const command = useCommand()
+
+ const initialSchedule = props.automation?.schedule ?? ""
+ const initialPrompt = props.automation?.prompt ?? ""
+ const parsed = parseBuilderSchedule(initialSchedule)
+
+ const [store, setStore] = createStore({
+ name: props.automation?.name ?? "",
+ prompt: initialPrompt,
+ parts: promptParts(initialPrompt),
+ enabled: props.automation?.enabled ?? true,
+ schedule: initialSchedule,
+ mode: parsed ? ("picker" as const) : initialSchedule ? ("cron" as const) : ("picker" as const),
+ days: parsed?.days ?? defaultDays,
+ times: parsed?.times ?? defaultTimes,
+ projects: props.automation?.projects ?? [],
+ saving: false,
+ })
+
+ const [preview, setPreview] = createStore({
+ loading: false,
+ nextRun: undefined as number | undefined,
+ error: "",
+ })
+ let previewTimeout: ReturnType | undefined
+ let previewId = 0
+
+ const title = () => {
+ if (props.automation) return language.t("automations.edit.title")
+ return language.t("automations.create.title")
+ }
+
+ const projects = createMemo(() => {
+ return globalSync.data.project
+ .filter((project) => project.worktree && project.worktree !== "/")
+ .slice()
+ .sort((a, b) => projectLabel(a).localeCompare(projectLabel(b)))
+ })
+ const home = createMemo(() => globalSync.data.path.home)
+ const dayOptions = createMemo(() => [
+ { key: "1", label: language.t("automations.day.mon") },
+ { key: "2", label: language.t("automations.day.tue") },
+ { key: "3", label: language.t("automations.day.wed") },
+ { key: "4", label: language.t("automations.day.thu") },
+ { key: "5", label: language.t("automations.day.fri") },
+ { key: "6", label: language.t("automations.day.sat") },
+ { key: "0", label: language.t("automations.day.sun") },
+ ])
+ const presets = createMemo(() => [
+ {
+ id: "weekdays",
+ label: language.t("automations.form.presets.weekdays"),
+ days: defaultDays,
+ times: defaultTimes,
+ },
+ {
+ id: "daily",
+ label: language.t("automations.form.presets.daily"),
+ days: dayOrder,
+ times: defaultTimes,
+ },
+ ])
+
+ const directory = createMemo(() => store.projects[0] ?? "")
+ const clients = new Map>()
+ const clientFor = (dir: string) => {
+ const cached = clients.get(dir)
+ if (cached) return cached
+ const next = createOpencodeClient({
+ baseUrl: globalSDK.url,
+ fetch: platform.fetch,
+ directory: dir,
+ throwOnError: true,
+ })
+ clients.set(dir, next)
+ return next
+ }
+
+ const [customCommands] = createResource(directory, (dir) => {
+ if (!dir) return []
+ return clientFor(dir)
+ .command.list()
+ .then((x) => x.data ?? [])
+ .catch(() => [])
+ })
+
+ const [agents] = createResource(directory, (dir) => {
+ if (!dir) return []
+ return clientFor(dir)
+ .app.agents()
+ .then((x) => x.data ?? [])
+ .catch(() => [])
+ })
+
+ const searchFiles = async (query: string) => {
+ const dir = directory()
+ if (!dir) return []
+ return clientFor(dir)
+ .find.files({ query, dirs: "true" })
+ .then((x) => x.data ?? [])
+ .catch(() => [])
+ }
+
+ const slashCommands = createMemo(() => {
+ const builtin = command.options
+ .filter((opt) => !opt.disabled && !opt.id.startsWith("suggested.") && opt.slash)
+ .map((opt) => ({
+ id: opt.id,
+ trigger: opt.slash!,
+ title: opt.title,
+ description: opt.description,
+ keybind: opt.keybind,
+ type: "builtin" as const,
+ }))
+ const custom = (customCommands() ?? []).map((cmd) => ({
+ id: `custom.${cmd.name}`,
+ trigger: cmd.name,
+ title: cmd.name,
+ description: cmd.description,
+ type: "custom" as const,
+ source: cmd.source,
+ }))
+ return [...custom, ...builtin]
+ })
+
+ const templates = createMemo(() => [
+ {
+ value: "{{date}}",
+ label: language.t("automations.template.date.label"),
+ description: language.t("automations.template.date.description"),
+ },
+ {
+ value: "{{project.name}}",
+ label: language.t("automations.template.project.label"),
+ description: language.t("automations.template.project.description"),
+ },
+ {
+ value: "{{session.latest}}",
+ label: language.t("automations.template.sessionLatest.label"),
+ description: language.t("automations.template.sessionLatest.description"),
+ },
+ {
+ value: "{{session.query:term}}",
+ label: language.t("automations.template.sessionQuery.label"),
+ description: language.t("automations.template.sessionQuery.description"),
+ },
+ ])
+
+ const scheduleValue = createMemo(() => {
+ if (store.mode === "cron") return store.schedule.trim()
+ return buildSchedule(store.days, store.times)
+ })
+ const scheduleError = createMemo(() => {
+ if (!store.enabled) return ""
+ if (!scheduleValue()) return language.t("automations.form.schedule.invalid")
+ if (preview.loading) return ""
+ if (!preview.error) return ""
+
+ return language.t("automations.form.schedule.invalidCron", { error: preview.error })
+ })
+ const schedulePreview = createMemo(() => {
+ if (!store.enabled) return ""
+ if (!scheduleValue()) return ""
+ if (preview.loading) return language.t("automations.form.schedule.calculating")
+ if (preview.error) return ""
+
+ const next = preview.nextRun
+ const label = next
+ ? DateTime.fromMillis(next).toLocaleString(DateTime.DATETIME_SHORT)
+ : language.t("automations.time.never")
+ return `${language.t("automations.form.schedule.nextRun")}: ${label}`
+ })
+
+ const handlePromptChange = (value: Prompt) => {
+ setStore({ parts: value, prompt: promptText(value) })
+ }
+ const scheduleReady = createMemo(() => {
+ if (!store.enabled) return true
+
+ if (!scheduleValue()) return false
+ if (preview.loading) return false
+ if (preview.error) return false
+
+ return true
+ })
+ const canSave = createMemo(() => {
+ if (!store.name.trim()) return false
+ if (!store.prompt.trim()) return false
+ if (store.projects.length === 0) return false
+ if (!scheduleReady()) return false
+
+ return true
+ })
+
+ createEffect(
+ on([() => store.enabled, scheduleValue], ([enabled, schedule]) => {
+ previewId += 1
+ const current = previewId
+ if (previewTimeout) clearTimeout(previewTimeout)
+ if (!enabled || !schedule) {
+ setPreview({ loading: false, nextRun: undefined, error: "" })
+ return
+ }
+ setPreview({ loading: true, nextRun: undefined, error: "" })
+ previewTimeout = setTimeout(() => {
+ globalSDK.client.automation
+ .preview({ schedule })
+ .then((result) => {
+ if (current !== previewId) return
+ const data = result.data
+ setPreview({
+ loading: false,
+ nextRun: data?.nextRun,
+ error: data?.error ?? "",
+ })
+ })
+ .catch((error) => {
+ if (current !== previewId) return
+ const message = error instanceof Error ? error.message : String(error)
+ setPreview({ loading: false, nextRun: undefined, error: message })
+ })
+ }, 200)
+ }),
+ )
+
+ onCleanup(() => {
+ if (previewTimeout) clearTimeout(previewTimeout)
+ previewId += 1
+ })
+
+ function updateSchedule(value: string) {
+ setStore("schedule", value)
+ }
+
+ function toggleDay(day: string, checked: boolean) {
+ if (checked) {
+ setStore("days", (prev) => sortDays([...prev, day]))
+ return
+ }
+
+ setStore("days", (prev) => prev.filter((item) => item !== day))
+ }
+
+ function updateTime(index: number, value: string) {
+ setStore("times", (prev) => prev.map((item, i) => (i === index ? value : item)))
+ }
+
+ function addTime() {
+ setStore("times", (prev) => [...prev, "09:00"])
+ }
+
+ function applyPreset(preset: { days: string[]; times: string[] }) {
+ setStore({ mode: "picker", days: [...preset.days], times: [...preset.times] })
+ }
+
+ function removeTime(index: number) {
+ setStore("times", (prev) => prev.filter((_, i) => i !== index))
+ }
+
+ function toggleProject(directory: string, checked: boolean) {
+ if (checked) {
+ setStore("projects", (prev) => {
+ if (prev.includes(directory)) return prev
+ return [...prev, directory]
+ })
+ return
+ }
+
+ setStore("projects", (prev) => prev.filter((item) => item !== directory))
+ }
+
+ async function handleSave() {
+ if (!canSave()) return
+ setStore("saving", true)
+
+ const schedule = scheduleValue()
+ const savedSchedule = schedule ? schedule : null
+ const payload = {
+ name: store.name.trim(),
+ projects: store.projects,
+ prompt: store.prompt,
+ schedule: savedSchedule,
+ enabled: store.enabled,
+ }
+ const request = props.automation
+ ? globalSDK.client.automation.update({ automationID: props.automation.id, ...payload })
+ : globalSDK.client.automation.create(payload)
+
+ await request.then(() => dialog.close()).finally(() => setStore("saving", false))
+ }
+
+ const deleteAutomation = async (automation: Automation) => {
+ await globalSDK.client.automation.remove({ automationID: automation.id })
+ }
+
+ const openDelete = () => {
+ const automation = props.automation
+ if (!automation) return
+ dialog.show(() => )
+ }
+
+ return (
+
+
+
+ )
+}
diff --git a/packages/app/src/components/dialog-confirm.tsx b/packages/app/src/components/dialog-confirm.tsx
new file mode 100644
index 00000000000..5c1c90296a9
--- /dev/null
+++ b/packages/app/src/components/dialog-confirm.tsx
@@ -0,0 +1,65 @@
+import { Button, type ButtonProps } from "@opencode-ai/ui/button"
+import { Dialog } from "@opencode-ai/ui/dialog"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { Show, type JSX } from "solid-js"
+import { createStore } from "solid-js/store"
+
+export function DialogConfirm(props: {
+ title: string
+ message?: JSX.Element
+ confirmLabel: string
+ cancelLabel: string
+ confirmVariant?: ButtonProps["variant"]
+ onConfirm: () => Promise | void
+ onCancel?: () => void
+}) {
+ const dialog = useDialog()
+ const [store, setStore] = createStore({
+ confirming: false,
+ })
+
+ const handleConfirm = async () => {
+ if (store.confirming) return
+ setStore("confirming", true)
+
+ await Promise.resolve(props.onConfirm())
+ .then(() => {
+ dialog.close()
+ })
+ .finally(() => {
+ setStore("confirming", false)
+ })
+ }
+
+ const handleCancel = () => {
+ if (store.confirming) return
+
+ props.onCancel?.()
+ dialog.close()
+ }
+
+ return (
+
+
+
+
+ {props.message}
+
+
+
+
+ {props.cancelLabel}
+
+
+ {props.confirmLabel}
+
+
+
+
+ )
+}
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 f4f49f055be..bcb1077a7d2 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 (
+
+
+
+ {state.error}
}>
+ 0}
+ fallback={{language.t("automations.history.empty")}
}
+ >
+
+
+
+
+
+
+
+
+
+
+
+ {language.t("automations.history.table.time")}
+ {language.t("automations.history.table.project")}
+ {language.t("automations.history.table.status")}
+ {language.t("automations.history.table.session")}
+
+
+
+
+
+ {(run) => (
+
+ {formatRunTime(run.time)}
+
+ {projectLabel(run.directory)}
+
+ {formatStatus(run)}
+
+ {run.sessionID ?? "-"}
+
+
+
+
+
+
+ openSession(
+ run.sessionID ? { id: run.sessionID, directory: run.directory } : undefined,
+ )
+ }
+ />
+
+
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+ )
+ }
+
+ const openDelete = (automation: Automation) => {
+ dialog.show(() => )
+ }
+
+ const openHistory = (automation: Automation) => {
+ dialog.show(() => )
+ }
+
+ return (
+
+
+
+
{language.t("automations.title")}
+
{language.t("automations.description")}
+
+
+ (importRef = el)}
+ type="file"
+ accept="application/json"
+ class="hidden"
+ onChange={handleImport}
+ />
+ importRef?.click()}>
+ {language.t("automations.import.action")}
+
+
+ {language.t("automations.export.all")}
+
+
+ {language.t("automations.history.clear.action")}
+
+
+ {language.t("automations.create.button")}
+
+
+
+
+
0}
+ fallback={
+
+
+
{language.t("automations.empty.title")}
+
{language.t("automations.empty.description")}
+
+ {language.t("automations.create.button")}
+
+
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+
+ {language.t("automations.table.name")}
+ {language.t("automations.table.schedule")}
+ {language.t("automations.table.next")}
+ {language.t("automations.table.last")}
+
+
+
+
+
+ {(automation) => (
+
+
+
+
+ {automation.name}
+
+
+ {language.t("automations.badge.disabled")}
+
+
+
+
{projectList(automation)}
+
+
+
+ {scheduleLabel(automation)}
+
+ {formatTime(automation.nextRun)}
+ {formatTime(automation.lastRun)}
+
+
+
+
+ openSession(automation.lastSession)}
+ />
+
+
+
+ openHistory(automation)} />
+
+
+ openEdit(automation)} />
+
+
+ exportAutomation(automation)} />
+
+
+ openDelete(automation)} />
+
+
+ runAutomation(automation)}
+ />
+
+
+
+
+ )}
+
+
+
+
+
+
+ )
+}
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index 59adef4694a..39a29b58a01 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -936,6 +936,12 @@ export default function Layout(props: ParentProps) {
keybind: "mod+comma",
onSelect: () => openSettings(),
},
+ {
+ id: "automations.open",
+ title: language.t("command.automations.open"),
+ category: language.t("command.category.settings"),
+ onSelect: () => openAutomations(),
+ },
{
id: "session.previous",
title: language.t("command.session.previous"),
@@ -1084,6 +1090,11 @@ export default function Layout(props: ParentProps) {
dialog.show(() => )
}
+ function openAutomations() {
+ navigate("/automations")
+ layout.mobileSidebar.hide()
+ }
+
function navigateToProject(directory: string | undefined) {
if (!directory) return
if (!layout.sidebar.opened()) {
@@ -1918,6 +1929,9 @@ export default function Layout(props: ParentProps) {
renderProjectOverlay={() => (
layout.projects.list()} activeProject={() => store.activeProject} />
)}
+ automationsLabel={() => language.t("sidebar.automations")}
+ automationsKeybind={() => command.keybind("automations.open")}
+ onOpenAutomations={openAutomations}
settingsLabel={() => language.t("sidebar.settings")}
settingsKeybind={() => command.keybind("settings.open")}
onOpenSettings={openSettings}
@@ -1981,6 +1995,9 @@ export default function Layout(props: ParentProps) {
renderProjectOverlay={() => (
layout.projects.list()} activeProject={() => store.activeProject} />
)}
+ automationsLabel={() => language.t("sidebar.automations")}
+ automationsKeybind={() => command.keybind("automations.open")}
+ onOpenAutomations={openAutomations}
settingsLabel={() => language.t("sidebar.settings")}
settingsKeybind={() => command.keybind("settings.open")}
onOpenSettings={openSettings}
diff --git a/packages/app/src/pages/layout/sidebar-shell.tsx b/packages/app/src/pages/layout/sidebar-shell.tsx
index ce96a09d114..7c5441ce98b 100644
--- a/packages/app/src/pages/layout/sidebar-shell.tsx
+++ b/packages/app/src/pages/layout/sidebar-shell.tsx
@@ -26,6 +26,9 @@ export const SidebarContent = (props: {
openProjectKeybind: Accessor
onOpenProject: () => void
renderProjectOverlay: () => JSX.Element
+ automationsLabel?: Accessor
+ automationsKeybind?: Accessor
+ onOpenAutomations?: () => void
settingsLabel: Accessor
settingsKeybind: Accessor
onOpenSettings: () => void
@@ -78,6 +81,21 @@ export const SidebarContent = (props: {
+
+
+ props.onOpenAutomations?.()}
+ aria-label={props.automationsLabel?.()}
+ />
+
+
`,
+ automate: `
`,
"arrow-up": `
`,
"arrow-left": `
`,
"arrow-right": `
`,
@@ -31,6 +32,7 @@ const icons = {
"magnifying-glass": `
`,
"plus-small": `
`,
plus: `
`,
+ play: `
`,
"pencil-line": `
`,
mcp: `
`,
glasses: `
`,
@@ -59,6 +61,7 @@ const icons = {
check: `
`,
photo: `
`,
share: `
`,
+ sparkles: `
`,
download: `
`,
menu: `
`,
server: `
`,
diff --git a/packages/ui/src/components/select.tsx b/packages/ui/src/components/select.tsx
index b370dbb6456..df04d8f1572 100644
--- a/packages/ui/src/components/select.tsx
+++ b/packages/ui/src/components/select.tsx
@@ -1,5 +1,5 @@
import { Select as Kobalte } from "@kobalte/core/select"
-import { createMemo, onCleanup, splitProps, type ComponentProps, type JSX } from "solid-js"
+import { Show, createMemo, onCleanup, splitProps, type ComponentProps, type JSX } from "solid-js"
import { pipe, groupBy, entries, map } from "remeda"
import { Button, ButtonProps } from "./button"
import { Icon } from "./icon"
@@ -19,6 +19,7 @@ export type SelectProps
= Omit>, "value" | "
children?: (item: T | undefined) => JSX.Element
triggerStyle?: JSX.CSSProperties
triggerVariant?: "settings"
+ portal?: boolean
}
export function Select(props: SelectProps & Omit) {
@@ -38,6 +39,7 @@ export function Select(props: SelectProps & Omit)
"children",
"triggerStyle",
"triggerVariant",
+ "portal",
])
const state = {
@@ -154,18 +156,34 @@ export function Select(props: SelectProps & Omit)
-
-
-
-
-
+
+
+
+ }
+ >
+
+
+
+
+
+
)
}
diff --git a/packages/util/src/slugify.ts b/packages/util/src/slugify.ts
new file mode 100644
index 00000000000..d9116ca6a6d
--- /dev/null
+++ b/packages/util/src/slugify.ts
@@ -0,0 +1,7 @@
+export function slugify(input: string) {
+ return input
+ .toLowerCase()
+ .trim()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "")
+}
diff --git a/packages/web/src/content/docs/automations.mdx b/packages/web/src/content/docs/automations.mdx
new file mode 100644
index 00000000000..cd28c993064
--- /dev/null
+++ b/packages/web/src/content/docs/automations.mdx
@@ -0,0 +1,71 @@
+---
+title: Automations
+description: Schedule and run prompts across projects.
+---
+
+Automations let you schedule or manually run prompts across one or more projects. Each run creates a new session and records history, so you can review results later.
+
+---
+
+## Create an automation
+
+1. Open **Automations** in the web or desktop app.
+2. Click **Create automation**.
+3. Fill in:
+ - **Name**
+ - **Projects** to target
+ - **Prompt** (supports templates)
+ - **Schedule** (optional)
+
+If you leave the schedule empty, the automation is manual-only.
+
+---
+
+## Schedule builder
+
+The schedule builder has two modes:
+
+- **Picker**: choose days of the week and one or more times.
+- **Cron**: enter a cron expression directly.
+
+When a schedule is valid, the UI shows the next run time. Invalid schedules show an error message until corrected.
+
+---
+
+## Templates
+
+Prompts support templates that are resolved at run time:
+
+- `{{date}}`
+- `{{project.name}}`
+- `{{session.latest}}`
+- `{{session.query:term}}`
+
+Use these to create dynamic prompts that adapt to the project and recent sessions.
+
+---
+
+## Run history
+
+Each automation keeps a run history with:
+
+- Run time
+- Project directory
+- Status (success/failed)
+- Session ID (when a session was created)
+
+You can clear run history from the Automations page.
+
+---
+
+## Import and export
+
+- **Export** a single automation or all automations as JSON.
+- **Import** automations from JSON to create them in the current workspace.
+
+---
+
+## Enable, disable, and delete
+
+- Toggle **Enabled** to stop scheduled runs without deleting the automation.
+- Delete automations to remove schedules and history.
diff --git a/packages/web/src/content/docs/index.mdx b/packages/web/src/content/docs/index.mdx
index bb3b8cb5d00..308a5b3bf0a 100644
--- a/packages/web/src/content/docs/index.mdx
+++ b/packages/web/src/content/docs/index.mdx
@@ -196,6 +196,8 @@ used.
You are now ready to use OpenCode to work on your project. Feel free to ask it
anything!
+Learn more about scheduled workflows in [Automations](/docs/automations).
+
If you are new to using an AI coding agent, here are some examples that might
help.
diff --git a/packages/web/src/content/docs/web.mdx b/packages/web/src/content/docs/web.mdx
index 52b97460c49..874258ba737 100644
--- a/packages/web/src/content/docs/web.mdx
+++ b/packages/web/src/content/docs/web.mdx
@@ -94,6 +94,10 @@ The username defaults to `opencode` but can be changed with `OPENCODE_SERVER_USE
Once started, the web interface provides access to your OpenCode sessions.
+### Automations
+
+Use the Automations page to schedule prompts across projects or run them on demand. Learn more in the [Automations](/docs/automations) guide.
+
### Sessions
View and manage your sessions from the homepage. You can see active sessions and start new ones.