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 ( + + ) +} 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={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 ( + +
{ + e.preventDefault() + handleSave() + }} + class="flex flex-col h-full min-h-0" + > +
+
+
+ setStore("name", value)} + /> + +
+
+
+
+ {language.t("automations.form.projects")} + + {language.t("automations.form.projects.count", { count: store.projects.length })} + +
+
+
+ 0} + fallback={ +
+ {language.t("automations.form.projects.empty")} +
+ } + > + + {(project) => ( +
+ toggleProject(project.worktree, checked)} + description={project.worktree.replace(home(), "~")} + > + {projectLabel(project)} + +
+ )} +
+
+
+
+
+ +
+
+ {language.t("automations.form.prompt")} + + {language.t("automations.form.prompt.description")} + +
+ +
+ +
+
+
+ {language.t("automations.form.schedule")} + {language.t("automations.form.schedule.hint")} +
+ setStore("enabled", value)} hideLabel> + {language.t("automations.form.schedule.enabled")} + +
+ + +
+
+ {language.t("automations.form.presets")} +
+ + {(preset) => ( + + )} + +
+
+ + value === "picker" + ? language.t("automations.form.schedule.mode.picker") + : language.t("automations.form.schedule.mode.cron") + } + onSelect={(value) => { + if (!value) return + if (value === "cron") { + setStore({ mode: value, schedule: scheduleValue() }) + return + } + const parsedSchedule = parseBuilderSchedule(store.schedule) + const cron = store.schedule.trim() + if (cron && !parsedSchedule) return + setStore("mode", value) + if (parsedSchedule) { + setStore({ days: parsedSchedule.days, times: parsedSchedule.times }) + } + }} + /> + + +
+ +
+ + {(day) => ( + toggleDay(day.key, value)} + /> + )} + +
+
+ +
+
+ + +
+
+ 0} + fallback={ +
+ {language.t("automations.form.times.empty")} +
+ } + > +
+ + {(time, index) => ( + updateTime(index(), value)} + onRemove={() => removeTime(index())} + /> + )} + +
+
+
+
+ + +
+ + + + + + +
{schedulePreview()}
+
+ +
{scheduleError()}
+
+
+
+
+
+
+
+ +
+
+ }> + + +
+ + +
+
+
+
+
+ ) +} 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} +
+
+
+ + +
+
+
+ ) +} 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")}
} + > +
+ + + + + + + + + + + + + + + + + + + {(run) => ( + + + + + + + + )} + + +
{language.t("automations.history.table.time")}{language.t("automations.history.table.project")}{language.t("automations.history.table.status")}{language.t("automations.history.table.session")}
{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} + /> + + + + +
+
+ + 0} + fallback={ +
+ +
{language.t("automations.empty.title")}
+
{language.t("automations.empty.description")}
+ +
+ } + > +
+ + + + + + + + + + + + + + + + + + + {(automation) => ( + + + + + + + + )} + + +
{language.t("automations.table.name")}{language.t("automations.table.schedule")}{language.t("automations.table.next")}{language.t("automations.table.last")}
+
+
+ {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.