Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
84957ca
feat(automation): wire global-sync data layer for automations panel
Astro-Han Jun 2, 2026
2ee5d96
feat(automation): add automations shell surface and sidebar entry
Astro-Han Jun 2, 2026
6617f33
feat(automation): add pause/resume row action and mutation wiring
Astro-Han Jun 2, 2026
70af3f1
feat(automation): build automation detail view with runs and lifecycl…
Astro-Han Jun 2, 2026
1a7a84d
feat(automation): unhide automate tool and pin its v1 input surface
Astro-Han Jun 2, 2026
a53dd5c
feat(automation): subtle toast when a recurring automation keeps failing
Astro-Han Jun 2, 2026
90994f3
test(automation): cover the Automations panel user path end to end
Astro-Han Jun 2, 2026
e5735db
fix(automation): correct schedule labels and harden panel detail rend…
Astro-Han Jun 2, 2026
0e2e4ee
fix(automation): keep live store state across bootstrap merge and mut…
Astro-Han Jun 2, 2026
5d80154
fix(automation): let sidebar menus consume Escape before the panel
Astro-Han Jun 2, 2026
68da1f6
refactor(automation): flatten the automate tool to a flat cron surface
Astro-Han Jun 2, 2026
303b157
fix(automation): clear session chrome while the Automations surface i…
Astro-Han Jun 2, 2026
7d51fa9
feat(automation): add play/pause icons, refine detail action row
Astro-Han Jun 2, 2026
d4d53c4
test(automation): register automations-panel smoke spec in e2e inventory
Astro-Han Jun 2, 2026
fbb1c0d
fix(automation): name weekday in weekly summary, scope runs list to r…
Astro-Han Jun 2, 2026
ad54353
Merge origin/dev into claude/automation-pr6
Astro-Han Jun 2, 2026
9a7b7a0
fix(automations): assert toggle action via aria-label for icon-only b…
Astro-Han Jun 2, 2026
a70b220
fix(automate): scope session-model errors and sample now after valida…
Astro-Han Jun 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions packages/app/e2e/automations/automations-panel.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { test, expect } from "../fixtures"
import { openSidebar } from "../actions"

const recurring = (projectID: string, title: string, prompt: string, expression: string) => ({
automationCreateInput: {
kind: "recurring" as const,
title,
prompt,
context: "fresh" as const,
where: { projectID },
timezone: "UTC",
model: { providerID: "opencode", modelID: "big-pickle" },
rhythm: { kind: "cron" as const, expression },
stop: { kind: "never" as const },
},
})

test("@smoke automations panel: list, detail, pause, delete", async ({ page, project }) => {
test.setTimeout(120_000)

await project.open()
await openSidebar(page)

const toggle = page.locator('[data-action="pawwork-automations-open"]')
await toggle.click()

const surface = page.locator('[data-component="automations-page"]')
await expect(surface).toBeVisible()
await expect(surface.locator('[data-component="automations-empty"]')).toBeVisible()

// Unlike the Settings takeover, opening Automations keeps the sidebar live: its
// toggle stays mounted and pressed, and the settings nav never replaces it.
await expect(toggle).toHaveAttribute("aria-pressed", "true")
await expect(page.locator('[data-component="settings-nav"]')).toHaveCount(0)

// Seed through the SDK; the live SSE event populates the list without a reload.
const projectID = (await project.sdk.project.current()).data!.id
await project.sdk.automation.create(
recurring(projectID, "Daily standup digest", "Summarize overnight changes and list open PRs.", "0 9 * * *"),
)

const rows = surface.locator('[data-action="automation-row"]')
await expect(rows).toHaveCount(1)

await rows.first().click()
const detail = surface.locator('[data-component="automation-detail"]')
await expect(detail).toBeVisible()
await expect(detail.getByRole("heading", { name: "Daily standup digest" })).toBeVisible()

// Pause flips the icon-only action's aria-label to Resume and the status row to Paused.
await detail.locator('[data-action="automation-toggle-active"]').click()
await expect(detail.locator('[data-action="automation-toggle-active"]')).toHaveAttribute("aria-label", "Resume")
await expect(detail.getByText("Paused")).toBeVisible()

// Delete confirms through a dialog and drops back to the empty list.
await detail.locator('[data-action="automation-delete"]').click()
const dialog = page.locator('[data-component="dialog"]')
await expect(dialog).toBeVisible()
await dialog.locator('[data-action="automation-delete-confirm"]').click()

await expect(surface.locator('[data-component="automations-empty"]')).toBeVisible()
await expect(rows).toHaveCount(0)
})

test("automations panel: escape unwinds detail then closes the surface", async ({ page, project }) => {
test.setTimeout(120_000)

await project.open()
await openSidebar(page)

const toggle = page.locator('[data-action="pawwork-automations-open"]')
await toggle.click()

const surface = page.locator('[data-component="automations-page"]')
await expect(surface).toBeVisible()

const projectID = (await project.sdk.project.current()).data!.id
await project.sdk.automation.create(
recurring(projectID, "Hourly build watch", "Check CI and flag a red main build.", "0 * * * *"),
)

const rows = surface.locator('[data-action="automation-row"]')
await expect(rows).toHaveCount(1)
await rows.first().click()
await expect(surface.locator('[data-component="automation-detail"]')).toBeVisible()

// First Escape returns to the list, second Escape closes the surface entirely.
await page.keyboard.press("Escape")
await expect(surface.locator('[data-component="automation-detail"]')).toHaveCount(0)
await expect(rows).toHaveCount(1)

await page.keyboard.press("Escape")
await expect(surface).toHaveCount(0)
await expect(toggle).toHaveAttribute("aria-pressed", "false")
})
61 changes: 61 additions & 0 deletions packages/app/e2e/snap/automations-surface.snap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { test } from "../fixtures"
import { openSidebar } from "../actions"
import { composeGrid, snapOutputPath, type Shot } from "./_compose"

test.use({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2 })

const recurring = (projectID: string, title: string, prompt: string, expression: string) => ({
automationCreateInput: {
kind: "recurring" as const,
title,
prompt,
context: "fresh" as const,
where: { projectID },
timezone: "UTC",
model: { providerID: "opencode", modelID: "big-pickle" },
rhythm: { kind: "cron" as const, expression },
stop: { kind: "never" as const },
},
})

test("automations-surface", async ({ page, project }) => {
test.setTimeout(180_000)

await project.open()
await openSidebar(page)

await page.locator('[data-action="pawwork-automations-open"]').click()
const surface = page.locator('[data-component="automations-page"]')
await surface.waitFor({ state: "visible", timeout: 30_000 })
await surface.locator('[data-component="automations-empty"]').waitFor({ state: "visible", timeout: 30_000 })
const empty = await page.screenshot()

// Seed via SDK; the live SSE event populates the list without a reload.
const projectID = (await project.sdk.project.current()).data!.id
await project.sdk.automation.create(recurring(projectID, "Daily standup digest", "Summarize overnight changes and list open PRs.", "0 9 * * *"))
await project.sdk.automation.create(recurring(projectID, "Hourly build watch", "Check CI and flag a red main build.", "0 * * * *"))

const rows = surface.locator('[data-action="automation-row"]')
await rows.first().waitFor({ state: "visible", timeout: 30_000 })
await page.waitForFunction(() => document.querySelectorAll('[data-action="automation-row"]').length >= 2)
const list = await page.screenshot()

// Hover a row to reveal the one-click pause/resume action.
await rows.first().hover()
await surface.locator('[data-action="automation-toggle-active"]').first().waitFor({ state: "visible", timeout: 10_000 })
const listHover = await page.screenshot()

await rows.first().click()
await surface.locator('[data-component="automation-detail"]').waitFor({ state: "visible", timeout: 30_000 })
const detail = await page.screenshot()

const shots: Shot[] = [
{ name: "empty", buf: empty },
{ name: "list", buf: list },
{ name: "list-hover", buf: listHover },
{ name: "detail", buf: detail },
]
const out = snapOutputPath("automations-surface")
await composeGrid(shots, out)
process.stdout.write(`\n[snap] automations-surface grid -> ${out}\n\n`)
})
42 changes: 42 additions & 0 deletions packages/app/src/components/dialog-delete-automation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Dialog } from "@opencode-ai/ui/dialog"
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createSignal } from "solid-js"
import { useLanguage } from "@/context/language"

export function DialogDeleteAutomation(props: {
title: string
onConfirm: () => Promise<void> | void
}) {
const language = useLanguage()
const dialog = useDialog()
const [deleting, setDeleting] = createSignal(false)

const handleDelete = async () => {
if (deleting()) return
setDeleting(true)
try {
await props.onConfirm()
dialog.close()
} finally {
setDeleting(false)
}
}

return (
<Dialog title={language.t("automations.delete.title")} fit class="w-full max-w-[420px] mx-auto">
<div class="px-6 pt-2 pb-6">
<span class="text-body text-fg-strong">{language.t("automations.delete.confirm", { title: props.title })}</span>
<p class="mt-2 text-body text-fg-weak">{language.t("automations.delete.description")}</p>
</div>
<div class="flex justify-end gap-2 px-6 pb-6">
<Button variant="secondary" onClick={() => dialog.close()} disabled={deleting()}>
{language.t("common.cancel")}
</Button>
<Button variant="danger" data-action="automation-delete-confirm" onClick={handleDelete} disabled={deleting()}>
{language.t("automations.action.delete")}
</Button>
</div>
</Dialog>
)
}
4 changes: 2 additions & 2 deletions packages/app/src/components/session/session-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ export function SessionHeader() {

return (
<>
<Show when={!shellSurface.settingsOpen() && leftMount()}>
<Show when={!shellSurface.settingsOpen() && !shellSurface.automationsOpen() && leftMount()}>
{(mount) => (
<Portal mount={mount()}>
<div class="hidden md:flex w-full min-w-0 max-w-[720px] items-center overflow-hidden text-h3">
Expand Down Expand Up @@ -142,7 +142,7 @@ export function SessionHeader() {
</Portal>
)}
</Show>
<Show when={!shellSurface.settingsOpen() && rightMount()}>
<Show when={!shellSurface.settingsOpen() && !shellSurface.automationsOpen() && rightMount()}>
{(mount) => (
<Portal mount={mount()}>
<Show
Expand Down
85 changes: 85 additions & 0 deletions packages/app/src/context/global-sync.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import type {
Config,
OpencodeClient,
Expand Down Expand Up @@ -31,6 +31,12 @@
import { clearSessionPrefetchDirectory } from "./global-sync/session-prefetch"
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
import { trimSessions } from "./global-sync/session-trim"
import {
applyAutomationDefinition,
applyAutomationRun,
applyAutomationTombstone,
mergeAutomationRuns,
} from "./global-sync/automation-store"
import type { ProjectMeta } from "./global-sync/types"
import { SESSION_RECENT_LIMIT } from "./global-sync/types"
import { createTodoHydrateCoordinator } from "./global-sync/todo-hydrate-coordinator"
Expand Down Expand Up @@ -321,6 +327,68 @@
return promise
}

async function loadAutomationRuns(directory: string, automationID: string, options?: { cursor?: string }) {
if (!directory || !automationID) return
children.pin(directory)
try {
const [store, setStore] = children.peek(directory, { bootstrap: false })
const sdk = sdkFor(directory)
const res = await sdk.automation.runs({ automationID, ...(options?.cursor ? { cursor: options.cursor } : {}) })
mergeAutomationRuns(store, setStore, res.data?.items ?? [])
return res.data?.nextCursor ?? null
} finally {
children.unpin(directory)
}
}

// Mutations apply the authoritative response immediately (revision-gated), so
// the UI reflects the change without waiting for the SSE round-trip; the
// matching event then no-ops as an equal revision.
async function pauseAutomation(directory: string, automationID: string) {
children.pin(directory)
try {
const [store, setStore] = children.peek(directory, { bootstrap: false })
const res = await sdkFor(directory).automation.pause({ automationID })
if (res.data) applyAutomationDefinition(store, setStore, res.data)
} finally {
children.unpin(directory)
}
}

async function resumeAutomation(directory: string, automationID: string) {
children.pin(directory)
try {
const [store, setStore] = children.peek(directory, { bootstrap: false })
const res = await sdkFor(directory).automation.resume({ automationID })
if (res.data) applyAutomationDefinition(store, setStore, res.data)
} finally {
children.unpin(directory)
}
}

async function deleteAutomation(directory: string, automationID: string) {
children.pin(directory)
try {
const [store, setStore] = children.peek(directory, { bootstrap: false })
const res = await sdkFor(directory).automation.delete({ automationID })
if (res.data) applyAutomationTombstone(store, setStore, res.data)
} finally {
children.unpin(directory)
}
}

async function runAutomationNow(directory: string, automationID: string) {
children.pin(directory)
try {
const [store, setStore] = children.peek(directory, { bootstrap: false })
const res = await sdkFor(directory).automation.runNow({ automationID })
if (res.data) applyAutomationRun(store, setStore, res.data)
return res.data
} finally {
children.unpin(directory)
}
}

async function bootstrapInstance(directory: string) {
if (!directory) return
const pending = booting.get(directory)
Expand Down Expand Up @@ -413,6 +481,16 @@
todoHydrate,
blockerTerminals,
vcsCache: children.vcsCache.get(targetDirectory),
onAutomationFailureStreak: (definition) => {
showToast({
variant: "subtle",
title: language.t("automations.toast.failureStreak.title"),
description: language.t("automations.toast.failureStreak.description", {
title: definition.title,
count: definition.failureStreak,
}),
})
},
loadLsp: () => {
void sdkFor(targetDirectory)
.lsp.status()
Expand Down Expand Up @@ -563,6 +641,13 @@
bootstrap,
updateConfig,
project: projectApi,
automation: {
loadRuns: loadAutomationRuns,
pause: pauseAutomation,
resume: resumeAutomation,
delete: deleteAutomation,
runNow: runAutomationNow,
},
todo: {
set: setSessionTodo,
accept: acceptSessionTodo,
Expand Down
Loading
Loading