diff --git a/packages/app/e2e/automations/automations-panel.spec.ts b/packages/app/e2e/automations/automations-panel.spec.ts index 70530ea63..2b3759d5b 100644 --- a/packages/app/e2e/automations/automations-panel.spec.ts +++ b/packages/app/e2e/automations/automations-panel.spec.ts @@ -109,6 +109,30 @@ async function openAutomations(page: Parameters[0]) { return surface } +async function expectInternalHoverSurface(locator: ReturnType) { + await locator.hover() + await expect + .poll(async () => + locator.evaluate((element) => { + const style = getComputedStyle(element) + return { + backgroundColor: style.backgroundColor, + cursor: style.cursor, + height: Math.round(element.getBoundingClientRect().height), + textDecorationLine: style.textDecorationLine, + } + }), + ) + .toMatchObject({ + cursor: "default", + height: 30, + textDecorationLine: "none", + }) + await expect + .poll(async () => locator.evaluate((element) => getComputedStyle(element).backgroundColor)) + .not.toBe("rgba(0, 0, 0, 0)") +} + test("automations panel: create manually adds an automation", async ({ page, project }) => { test.setTimeout(120_000) @@ -595,19 +619,15 @@ test("automations panel: detail moves an automation to another open project", as const detail = surface.locator('[data-component="automation-detail"]') await expect(detail).toBeVisible() - // The Project row is a picker when another project is open. Moving is a - // create-in-target + delete-from-source pair: the automation appears in - // the target project's list, vanishes from the source, and the detail - // stays open on the recreated definition (new id, same fields). + // The Project row is a picker when another project is open. Moving updates + // the same automation in place: it appears in the target project's list, + // vanishes from the source, and the detail stays open on the same id. await detail.locator('[data-action="automation-edit-project"]').click() await page.locator(`[data-project="${otherProjectID}"]`).click() await expect - .poll(async () => - ((await otherSDK.automation.list()).data?.items ?? []).filter((item) => item.title === "Migrating digest") - .length, - ) - .toBe(1) + .poll(async () => ((await otherSDK.automation.list()).data?.items ?? []).find((item) => item.id === created.id)) + .toMatchObject({ id: created.id, title: "Migrating digest" }) await expect .poll(async () => ((await project.sdk.automation.list()).data?.items ?? []).filter((item) => item.id === created.id).length, @@ -620,6 +640,62 @@ test("automations panel: detail moves an automation to another open project", as } }) +test("automations panel: detail project picker stays available with one open project", async ({ page, project }) => { + test.setTimeout(120_000) + + await project.open() + + const projectID = (await project.sdk.project.current()).data!.id + await project.sdk.automation.create( + recurring(projectID, "Single project digest", "Summarize the current project.", "0 9 * * *"), + ) + + const surface = await openAutomations(page) + await surface.locator('[data-action="automation-row"]', { hasText: "Single project digest" }).first().click() + + const detail = surface.locator('[data-component="automation-detail"]') + await expect(detail).toBeVisible() + + const projectPicker = detail.locator('[data-action="automation-edit-project"]') + await expect(projectPicker).toBeVisible() + + await expectInternalHoverSurface(projectPicker) + await expectInternalHoverSurface(detail.locator('[data-action="automation-edit-schedule"]')) + await expectInternalHoverSurface(detail.locator('[data-action="automation-edit-model"]')) + + await projectPicker.click() + const menu = page.locator('[role="menu"][aria-label="Workspace"]') + await expect(menu).toBeVisible() + await expect(menu.locator(`[data-project="${projectID}"]`)).toBeVisible() + await menu.locator('[data-action="automation-folder-open-project"]').click() + await expect(menu).toBeHidden() + const directoryDialog = page.getByRole("dialog").filter({ has: page.getByPlaceholder("Search folders") }) + await expect(directoryDialog).toBeVisible() + await expect(surface).toBeVisible() + expect(new URL(page.url()).pathname).toBe("/automations") +}) + +test("automations panel: continue automation project stays read-only", async ({ page, project, assistant }) => { + test.setTimeout(120_000) + + await project.open() + await assistant.tool("automate", { + title: "Continue project loop", + prompt: "Keep checking the same conversation.", + cron: "0 8 * * *", + continueSession: true, + }) + await project.prompt("Loop this conversation every morning.") + + const surface = await openAutomations(page) + await surface.locator('[data-action="automation-row"]', { hasText: "Continue project loop" }).first().click() + + const detail = surface.locator('[data-component="automation-detail"]') + await expect(detail).toBeVisible() + await expect(detail.locator('[data-action="automation-edit-project"]')).toHaveCount(0) + await expectInternalHoverSurface(detail.locator('[data-action="automation-open-source"]')) +}) + test("automations panel: a rhythm the picker cannot express is read-only", async ({ page, project }) => { test.setTimeout(120_000) @@ -670,12 +746,10 @@ test("automations panel: a paused automation arrives paused after a move", async await detail.locator('[data-action="automation-edit-project"]').click() await page.locator(`[data-project="${otherProjectID}"]`).click() - // The recreated copy must keep the silence the user set, not wake up live. + // The moved definition must keep the silence the user set, not wake up live. await expect .poll(async () => { - const moved = ((await otherSDK.automation.list()).data?.items ?? []).find( - (item) => item.title === "Silenced digest", - ) + const moved = ((await otherSDK.automation.list()).data?.items ?? []).find((item) => item.id === created.id) return moved ? moved.paused : "missing" }) .toBe(true) @@ -687,7 +761,7 @@ test("automations panel: a paused automation arrives paused after a move", async } }) -test("automations panel: a failed pause during a move rolls back and keeps the source", async ({ +test("automations panel: a failed move keeps the source", async ({ page, project, backend, @@ -713,10 +787,10 @@ test("automations panel: a failed pause during a move rolls back and keeps the s const detail = surface.locator('[data-component="automation-detail"]') await expect(detail).toBeVisible() - // Re-applying the pause on the target fails. The move must fail as a whole: - // the fresh copy is deleted, the paused source is untouched, and the user - // is told — never a silenced automation suddenly live in another project. - await page.route("**/automation/*/pause*", (route) => route.fulfill({ status: 500, body: "{}" })) + await page.route(`**/automation/${created.id}`, (route) => { + if (route.request().method() !== "PUT") return route.continue() + return route.fulfill({ status: 500, body: "{}" }) + }) await detail.locator('[data-action="automation-edit-project"]').click() await page.locator(`[data-project="${otherProjectID}"]`).click() diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 49962f502..5f7056447 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -37,6 +37,7 @@ import { createPendingQuestionController } from "./global-sync/pending-question- import { pendingSessionIDsForDirectory, type PendingQuestionIndex } from "./global-sync/pending-question-index" import { applyAutomationDefinition, + applyAutomationMoveResult, applyAutomationRun, applyAutomationTombstone, mergeAutomationRuns, @@ -445,6 +446,38 @@ function createGlobalSync() { } } + async function moveAutomation(directory: string, automationID: string, targetProject: { id: string; worktree: string }) { + children.pin(directory) + children.pin(targetProject.worktree) + try { + const source = children.peek(directory, { bootstrap: false }) + const current = source[0].automation[automationID] + const res = await sdkFor(directory).automation.update({ + automationID, + automationUpdateInput: { + where: { + projectID: targetProject.id, + ...(current?.where.worktree ? { worktree: current.where.worktree } : {}), + }, + }, + }) + if (res.data) { + const target = children.peek(targetProject.worktree, { bootstrap: false }) + applyAutomationMoveResult({ + source, + target, + automationID, + targetProjectID: targetProject.id, + incoming: res.data, + }) + } + return res.data + } finally { + children.unpin(targetProject.worktree) + children.unpin(directory) + } + } + async function bootstrapInstance(directory: string) { if (!directory) return const pending = booting.get(directory) @@ -708,6 +741,7 @@ function createGlobalSync() { automation: { create: createAutomation, update: updateAutomation, + move: moveAutomation, loadRuns: loadAutomationRuns, pause: pauseAutomation, resume: resumeAutomation, diff --git a/packages/app/src/context/global-sync/automation-store.test.ts b/packages/app/src/context/global-sync/automation-store.test.ts index 78a013bed..c9021a40c 100644 --- a/packages/app/src/context/global-sync/automation-store.test.ts +++ b/packages/app/src/context/global-sync/automation-store.test.ts @@ -4,6 +4,7 @@ import { createStore } from "solid-js/store" import type { State } from "./types" import { applyAutomationDefinition, + applyAutomationMoveResult, applyAutomationRun, applyAutomationTombstone, canAcceptAutomationDefinition, @@ -12,7 +13,7 @@ import { mergeAutomationList, } from "./automation-store" -const definition = (input: { id: string; revision: number; title?: string; paused?: boolean }): AutomationDefinition => +const definition = (input: { id: string; revision: number; title?: string; paused?: boolean; projectID?: string }): AutomationDefinition => ({ kind: "recurring", id: input.id, @@ -21,7 +22,7 @@ const definition = (input: { id: string; revision: number; title?: string; pause revision: input.revision, paused: input.paused ?? false, context: "fresh", - where: { projectID: "prj_1" }, + where: { projectID: input.projectID ?? "prj_1" }, createdAt: 1, updatedAt: 1, timezone: "UTC", @@ -145,6 +146,46 @@ describe("applyAutomationTombstone", () => { }) }) +describe("applyAutomationMoveResult", () => { + test("tombstones the source and writes the target only after the response confirms the target project", () => { + const [source, setSource] = createStore(baseState({ automation: { a: definition({ id: "a", revision: 1, projectID: "source" }) } })) + const [target, setTarget] = createStore(baseState()) + const moved = definition({ id: "a", revision: 2, projectID: "target" }) + + const applied = applyAutomationMoveResult({ + source: [source, setSource], + target: [target, setTarget], + automationID: "a", + targetProjectID: "target", + incoming: moved, + }) + + expect(applied).toBe("target") + expect(source.automation["a"]).toBeUndefined() + expect(source.automation_tombstone["a"]).toBe(2) + expect(target.automation["a"]).toBe(moved) + }) + + test("falls back to the source update when a stale target does not match the response project", () => { + const [source, setSource] = createStore(baseState({ automation: { a: definition({ id: "a", revision: 1, projectID: "source" }) } })) + const [target, setTarget] = createStore(baseState()) + const response = definition({ id: "a", revision: 2, projectID: "actual" }) + + const applied = applyAutomationMoveResult({ + source: [source, setSource], + target: [target, setTarget], + automationID: "a", + targetProjectID: "stale", + incoming: response, + }) + + expect(applied).toBe("source") + expect(source.automation["a"]).toEqual(response) + expect(source.automation_tombstone["a"]).toBeUndefined() + expect(target.automation["a"]).toBeUndefined() + }) +}) + describe("applyAutomationRun", () => { test("upserts then ignores a stale run", () => { const [store, setStore] = createStore(baseState()) diff --git a/packages/app/src/context/global-sync/automation-store.ts b/packages/app/src/context/global-sync/automation-store.ts index ceedb96b9..9e86a086e 100644 --- a/packages/app/src/context/global-sync/automation-store.ts +++ b/packages/app/src/context/global-sync/automation-store.ts @@ -1,6 +1,6 @@ import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store" import type { AutomationDefinition, AutomationDefinitionTombstone, AutomationRun } from "@opencode-ai/sdk/v2/client" -import type { State } from "./types" +import type { ChildStoreTuple, State } from "./types" // Definitions and runs carry a monotonic `revision`; deletions carry a tombstone // revision. The HTTP list/get responses and the SSE events share that sequence, @@ -65,6 +65,28 @@ export function applyAutomationTombstone( return true } +export function applyAutomationMoveResult(input: { + source: ChildStoreTuple + target: ChildStoreTuple + automationID: string + targetProjectID: string + incoming: AutomationDefinition +}): "source" | "target" { + const [sourceStore, sourceSetStore] = input.source + if (input.incoming.where.projectID !== input.targetProjectID) { + applyAutomationDefinition(sourceStore, sourceSetStore, input.incoming) + return "source" + } + applyAutomationTombstone(sourceStore, sourceSetStore, { + id: input.automationID, + deleted: true, + revision: input.incoming.revision, + }) + const [targetStore, targetSetStore] = input.target + applyAutomationDefinition(targetStore, targetSetStore, input.incoming) + return "target" +} + export function applyAutomationRun( store: Store, setStore: SetStoreFunction, diff --git a/packages/app/src/pages/automations/automation-create-dialog.tsx b/packages/app/src/pages/automations/automation-create-dialog.tsx index bda28514b..4eecbc68f 100644 --- a/packages/app/src/pages/automations/automation-create-dialog.tsx +++ b/packages/app/src/pages/automations/automation-create-dialog.tsx @@ -36,6 +36,7 @@ export function AutomationCreateDialog(props: { directory: string projectID: string template?: AutomationTemplate + onOpenProject: () => void onCreated: (definition: AutomationDefinition) => void }): JSX.Element { const globalSync = useGlobalSync() @@ -216,6 +217,7 @@ export function AutomationCreateDialog(props: { { setDirectory(project.worktree) setProjectID(project.id) diff --git a/packages/app/src/pages/automations/automation-detail-editors.tsx b/packages/app/src/pages/automations/automation-detail-editors.tsx index ba165cf50..81aee4e98 100644 --- a/packages/app/src/pages/automations/automation-detail-editors.tsx +++ b/packages/app/src/pages/automations/automation-detail-editors.tsx @@ -109,7 +109,7 @@ export function EditableText(props: { } const ROW_VALUE_CLASS = - "min-w-0 truncate text-right text-body text-fg-base hover:text-fg-strong hover:underline focus-visible:text-fg-strong focus-visible:underline focus:outline-none cursor-default" + "h-[30px] min-w-0 truncate rounded-md px-2 text-right text-body text-fg-base hover:bg-row-hover-overlay hover:text-fg-strong focus-visible:bg-row-hover-overlay focus-visible:text-fg-strong focus:outline-none cursor-default" function EditorRow(props: { label: string; children: JSX.Element }): JSX.Element { return ( @@ -120,18 +120,18 @@ function EditorRow(props: { label: string; children: JSX.Element }): JSX.Element ) } -// The "Project" row: there is no server-side move (each project is its own -// instance and update rejects a foreign projectID), so "move" is the create -// card's folder picker driving a create-in-target + delete-from-source pair -// (see moveToProject in automation-detail). A continue automation stays -// read-only — it loops inside a conversation that only exists in its source -// project — as does everything else when no other project is open. +// The "Project" row moves fresh automations by updating their owner in place. +// A continue automation stays read-only because it loops inside a conversation +// that only exists in its source project. Fresh automations keep the picker +// clickable even with one project, so the user can open another project from +// this surface before moving it. export function ProjectEditorRow(props: { directory: Accessor automation: Accessor projectName: Accessor t: Translate onMove: (project: AutomationProject) => void + onOpenProject: () => void }): JSX.Element { const layout = useLayout() const projects = createMemo(() => @@ -140,15 +140,11 @@ export function ProjectEditorRow(props: { .filter((project) => project.id && project.id !== "global" && project.worktree) .map((project) => ({ id: project.id!, worktree: project.worktree, name: project.name })), ) - const movable = createMemo( - () => - props.automation().context === "fresh" && - projects().some((project) => project.id !== props.automation().where.projectID), - ) + const editable = createMemo(() => props.automation().context === "fresh" && projects().length > 0) return ( {props.projectName()}} > props.onMove(project)} + onOpenProject={props.onOpenProject} /> diff --git a/packages/app/src/pages/automations/automation-detail.tsx b/packages/app/src/pages/automations/automation-detail.tsx index 48b84d43b..55819611d 100644 --- a/packages/app/src/pages/automations/automation-detail.tsx +++ b/packages/app/src/pages/automations/automation-detail.tsx @@ -1,6 +1,5 @@ import { createEffect, createMemo, createSignal, For, Show, type Accessor, type JSX } from "solid-js" import type { - AutomationCreateInput, AutomationDefinition, AutomationRun, AutomationUpdateInput, @@ -110,6 +109,7 @@ export function AutomationDetail(props: { projectName: Accessor onBack: () => void onOpenRun: (sessionID: string) => void + onOpenProject: () => void onMoved: (definition: AutomationDefinition) => void }): JSX.Element { const globalSync = useGlobalSync() @@ -213,43 +213,13 @@ export function AutomationDetail(props: { } } - // There is no cross-project move on the server (per-project instances; - // update rejects a foreign projectID), so "move" recreates the definition in - // the target project, then deletes the source. Create-first so a failure - // loses nothing; if the source delete fails (e.g. a run in flight), the - // fresh copy is rolled back instead of leaving a duplicate. The id changes - // and the run list starts empty — past runs' sessions survive regardless. const moveToProject = async (project: AutomationProject) => { const previous = props.automation() if (busy() || project.id === previous.where.projectID) return setBusy(true) try { - const common = { - title: previous.title, - prompt: previous.prompt, - context: "fresh" as const, - where: { projectID: project.id, ...(previous.where.worktree ? { worktree: previous.where.worktree } : {}) }, - timezone: previous.timezone, - model: { providerID: previous.model.providerID, modelID: previous.model.modelID }, - ...(previous.variant ? { variant: previous.variant } : {}), - } - const input: AutomationCreateInput = - previous.kind === "oneshot" - ? { kind: "oneshot", ...common, fireAt: previous.fireAt } - : { kind: "recurring", ...common, rhythm: previous.rhythm, stop: previous.stop } - const created = await globalSync.automation.create(project.worktree, input) - if (!created) return - // A paused source must arrive paused: if the target pause fails, the - // move fails — roll the copy back rather than leave an automation the - // user silenced suddenly live in another project. - try { - if (previous.paused) await globalSync.automation.pause(project.worktree, created.id) - await globalSync.automation.delete(props.directory(), previous.id) - } catch (error) { - await globalSync.automation.delete(project.worktree, created.id).catch(() => {}) - throw error - } - props.onMoved(created) + const moved = await globalSync.automation.move(props.directory(), previous.id, project) + if (moved) props.onMoved(moved) } catch (error) { notifyFailure(error) } finally { @@ -356,6 +326,7 @@ export function AutomationDetail(props: { projectName={props.projectName} t={t} onMove={(project) => void moveToProject(project)} + onOpenProject={props.onOpenProject} /> props.onOpenRun(sourceSessionID())} - class="min-w-0 truncate text-right text-body text-fg-base hover:text-fg-strong hover:underline focus-visible:text-fg-strong focus-visible:underline focus:outline-none" + class="inline-flex h-[30px] min-w-0 items-center truncate rounded-md px-2 text-right text-body text-fg-base hover:bg-row-hover-overlay hover:text-fg-strong focus-visible:bg-row-hover-overlay focus-visible:text-fg-strong focus:outline-none cursor-default" > {sessionLabel()} diff --git a/packages/app/src/pages/automations/automation-folder-picker.tsx b/packages/app/src/pages/automations/automation-folder-picker.tsx index 1b78ae96e..77229cc1c 100644 --- a/packages/app/src/pages/automations/automation-folder-picker.tsx +++ b/packages/app/src/pages/automations/automation-folder-picker.tsx @@ -1,7 +1,8 @@ -import { For, Show, type JSX } from "solid-js" +import { createSignal, For, Show, type JSX } from "solid-js" import { Icon } from "@opencode-ai/ui/icon" import { Popover } from "@opencode-ai/ui/popover" import { getFilename } from "@opencode-ai/util/path" +import { useLanguage } from "@/context/language" import { workspaceKey } from "@/pages/layout/helpers" export interface AutomationProject { @@ -22,9 +23,12 @@ export function AutomationFolderPicker(props: { projects: AutomationProject[] current: string onSelect: (project: AutomationProject) => void + onOpenProject?: () => void variant?: "knob" | "row" action?: string }): JSX.Element { + const language = useLanguage() + const [open, setOpen] = createSignal(false) const isActive = (project: AutomationProject) => workspaceKey(project.worktree) === workspaceKey(props.current) const label = () => { const match = props.projects.find(isActive) @@ -35,6 +39,8 @@ export function AutomationFolderPicker(props: { return ( - - - - {label()} - - - } + trigger={[ + + + , + {label()}, + , + ]} > -