diff --git a/packages/app/src/pages/cron-stop-actions.test.ts b/packages/app/src/pages/cron-stop-actions.test.ts
new file mode 100644
index 000000000..8691936ad
--- /dev/null
+++ b/packages/app/src/pages/cron-stop-actions.test.ts
@@ -0,0 +1,65 @@
+import { describe, expect, test } from "bun:test"
+import {
+ getCronSessionStopAction,
+ getCronSidebarStopAction,
+ getCronTaskStopAction,
+} from "./cron-stop-model"
+
+describe("cron stop action models", () => {
+ test("returns a cron task stop action only for cancellable runs", () => {
+ let count = 0
+ expect(
+ getCronTaskStopAction({ status: "success", label: "Cancel run", onClick: () => count++ }),
+ ).toBeUndefined()
+
+ const action = getCronTaskStopAction({
+ status: "running",
+ label: "Cancel run",
+ disabled: true,
+ onClick: () => count++,
+ })
+ expect(action?.disabled).toBe(true)
+ action?.onClick(new MouseEvent("click"))
+ expect(count).toBe(1)
+ })
+
+ test("builds shared sidebar stop actions with the correct visibility classes", () => {
+ let count = 0
+ const desktop = getCronSidebarStopAction({
+ status: "running",
+ label: "Cancel run",
+ onClick: () => count++,
+ })
+ expect(desktop?.class).toContain("pointer-events-none")
+ expect(desktop?.class).toContain("group-hover/session:pointer-events-auto")
+
+ const mobile = getCronSidebarStopAction({
+ status: "queued",
+ mobile: true,
+ label: "Cancel run",
+ onClick: () => count++,
+ })
+ expect(mobile?.class).toContain("pointer-events-auto")
+ expect(mobile?.class).toContain("opacity-100")
+
+ mobile?.onClick(new MouseEvent("click"))
+ expect(count).toBe(1)
+ })
+
+ test("returns a cron session stop action only when the readonly dock should show it", () => {
+ let count = 0
+ expect(
+ getCronSessionStopAction({ visible: false, label: "Cancel run", onClick: () => count++ }),
+ ).toBeUndefined()
+
+ const action = getCronSessionStopAction({
+ visible: true,
+ label: "Cancel run",
+ stopping: true,
+ onClick: () => count++,
+ })
+ expect(action?.stopping).toBe(true)
+ action?.onClick(new MouseEvent("click"))
+ expect(count).toBe(1)
+ })
+})
diff --git a/packages/app/src/pages/cron-stop-actions.tsx b/packages/app/src/pages/cron-stop-actions.tsx
new file mode 100644
index 000000000..04e9ec65f
--- /dev/null
+++ b/packages/app/src/pages/cron-stop-actions.tsx
@@ -0,0 +1,90 @@
+import { Show } from "solid-js"
+import { Button } from "@codeplane-ai/ui/button"
+import { IconButton } from "@codeplane-ai/ui/icon-button"
+import { Tooltip } from "@codeplane-ai/ui/tooltip"
+import type { CronRunStatus } from "@/utils/cron-client"
+import {
+ getCronSessionStopAction,
+ getCronSidebarStopAction,
+ getCronTaskStopAction,
+ type StopClick,
+} from "./cron-stop-model"
+
+export function CronTaskStopAction(props: {
+ status?: CronRunStatus
+ label: string
+ disabled?: boolean
+ onClick: StopClick
+}) {
+ const action = getCronTaskStopAction(props)
+ return (
+
+ {(action) => (
+
+
+
+ )}
+
+ )
+}
+
+export function CronSidebarStopAction(props: {
+ status: CronRunStatus
+ label: string
+ disabled?: boolean
+ mobile?: boolean
+ onClick: StopClick
+}) {
+ const action = getCronSidebarStopAction(props)
+ return (
+
+ {(action) => (
+
+
+
+
+
+ )}
+
+ )
+}
+
+export function CronSessionStopAction(props: {
+ visible?: boolean
+ label: string
+ stopping?: boolean
+ onClick: StopClick
+}) {
+ const action = getCronSessionStopAction(props)
+ return (
+
+ {(action) => (
+
+ )}
+
+ )
+}
diff --git a/packages/app/src/pages/cron-stop-model.ts b/packages/app/src/pages/cron-stop-model.ts
new file mode 100644
index 000000000..8c5ca0868
--- /dev/null
+++ b/packages/app/src/pages/cron-stop-model.ts
@@ -0,0 +1,38 @@
+import type { CronRunStatus } from "@/utils/cron-client"
+import { canCancelCronRunStatus, cronSidebarStopButtonClass } from "./cron-stop"
+
+export type StopClick = (event: MouseEvent) => void
+
+export function getCronTaskStopAction(props: {
+ status?: CronRunStatus
+ label: string
+ disabled?: boolean
+ onClick: StopClick
+}) {
+ if (!canCancelCronRunStatus(props.status)) return
+ return props
+}
+
+export function getCronSidebarStopAction(props: {
+ status: CronRunStatus
+ label: string
+ disabled?: boolean
+ mobile?: boolean
+ onClick: StopClick
+}) {
+ if (!canCancelCronRunStatus(props.status)) return
+ return {
+ ...props,
+ class: `absolute right-1 top-1/2 -translate-y-1/2 ${cronSidebarStopButtonClass(props.mobile)}`,
+ }
+}
+
+export function getCronSessionStopAction(props: {
+ visible?: boolean
+ label: string
+ stopping?: boolean
+ onClick: StopClick
+}) {
+ if (!props.visible) return
+ return props
+}
diff --git a/packages/app/src/pages/cron-stop.test.ts b/packages/app/src/pages/cron-stop.test.ts
new file mode 100644
index 000000000..7e18d0cf0
--- /dev/null
+++ b/packages/app/src/pages/cron-stop.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, test } from "bun:test"
+import {
+ canCancelCronRunStatus,
+ canShowCronSessionStop,
+ cronSidebarStopButtonClass,
+} from "./cron-stop"
+
+describe("cron stop helpers", () => {
+ test("allows cancelling only queued or running runs", () => {
+ expect(canCancelCronRunStatus("queued")).toBe(true)
+ expect(canCancelCronRunStatus("running")).toBe(true)
+ expect(canCancelCronRunStatus("success")).toBe(false)
+ expect(canCancelCronRunStatus("failed")).toBe(false)
+ expect(canCancelCronRunStatus("timeout")).toBe(false)
+ expect(canCancelCronRunStatus("cancelled")).toBe(false)
+ expect(canCancelCronRunStatus(undefined)).toBe(false)
+ })
+
+ test("keeps the shared sidebar stop button visible on mobile", () => {
+ expect(cronSidebarStopButtonClass(true)).toContain("pointer-events-auto")
+ expect(cronSidebarStopButtonClass(true)).toContain("opacity-100")
+ expect(cronSidebarStopButtonClass(false)).toContain("group-hover/session:opacity-100")
+ expect(cronSidebarStopButtonClass(false)).toContain("opacity-0")
+ expect(cronSidebarStopButtonClass(false)).toContain("pointer-events-none")
+ expect(cronSidebarStopButtonClass(false)).toContain("group-hover/session:pointer-events-auto")
+ })
+
+ test("shows cron session stop only when a busy cron run is present", () => {
+ expect(canShowCronSessionStop({ sessionID: "session-1", runID: "run-1", busy: true })).toBe(true)
+ expect(canShowCronSessionStop({ sessionID: "session-1", runID: "run-1", busy: false })).toBe(false)
+ expect(canShowCronSessionStop({ sessionID: "session-1", runID: undefined, busy: true })).toBe(false)
+ expect(canShowCronSessionStop({ sessionID: undefined, runID: "run-1", busy: true })).toBe(false)
+ })
+})
diff --git a/packages/app/src/pages/cron-stop.ts b/packages/app/src/pages/cron-stop.ts
new file mode 100644
index 000000000..9e06eef3b
--- /dev/null
+++ b/packages/app/src/pages/cron-stop.ts
@@ -0,0 +1,15 @@
+import type { CronRunStatus } from "@/utils/cron-client"
+
+export const canCancelCronRunStatus = (status: CronRunStatus | undefined) =>
+ status === "queued" || status === "running"
+
+export const cronSidebarStopButtonClass = (mobile?: boolean) =>
+ mobile
+ ? "pointer-events-auto opacity-100"
+ : "pointer-events-none opacity-0 transition-opacity group-hover/session:pointer-events-auto group-hover/session:opacity-100 focus-within:pointer-events-auto focus-within:opacity-100"
+
+export const canShowCronSessionStop = (options: {
+ sessionID?: string
+ runID?: string
+ busy: boolean
+}) => !!options.sessionID && !!options.runID && options.busy
diff --git a/packages/app/src/pages/cron.tsx b/packages/app/src/pages/cron.tsx
index 14243a8af..8ddc537eb 100644
--- a/packages/app/src/pages/cron.tsx
+++ b/packages/app/src/pages/cron.tsx
@@ -28,6 +28,7 @@ import {
} from "@/utils/cron-client"
import { base64Encode } from "@codeplane-ai/shared/util/encode"
import { decode64 } from "@/utils/base64"
+import { CronTaskStopAction } from "./cron-stop-actions"
import { cronAgentOptions, type CronAgentOption } from "./cron-agents"
import { cronProjectForDirectory, cronProjectIDForRoute, cronTaskInScope, type CronProjectScope } from "./cron-scope"
@@ -268,6 +269,22 @@ function CronTaskRow(props: {
}),
}))
+ const cancelRun = useMutation(() => ({
+ mutationFn: async () => {
+ const conn = props.server()
+ if (!conn) throw new Error("No server connection")
+ if (!props.task.lastRunID) throw new Error("No run to cancel")
+ return CronClient.cancelRun(conn, props.task.lastRunID)
+ },
+ onSuccess: () => invalidate(),
+ onError: (err: unknown) =>
+ showToast({
+ variant: "error",
+ title: language.t("common.requestFailed"),
+ description: err instanceof Error ? err.message : String(err),
+ }),
+ }))
+
const remove = useMutation(() => ({
mutationFn: async () => {
if (!confirm(language.t("cron.delete.confirm"))) return
@@ -322,6 +339,12 @@ function CronTaskRow(props: {
+
cancelRun.mutate()}
+ />
(
)}
@@ -238,14 +244,18 @@ export const CronSidebarPanel = (props: {
const CronSessionRow = (props: {
sessionID?: string
+ runID: string
taskName: string
taskDirectory: string
projectID?: string
status: CronRunStatus
startedAt: number
sequence: number
+ server: Accessor
+ mobile?: boolean
}): JSX.Element => {
const language = useLanguage()
+ const queryClient = useQueryClient()
const slug = createMemo(() => base64Encode(props.taskDirectory))
const path = createMemo(() => `/cron/worktree/${slug()}/session/${props.sessionID}`)
const href = createMemo(() => {
@@ -256,6 +266,26 @@ const CronSessionRow = (props: {
})
const location = useLocation()
const isActive = createMemo(() => !!props.sessionID && location.pathname === path())
+ const cancelRun = useMutation(() => ({
+ mutationFn: async () => {
+ const conn = props.server()
+ if (!conn) throw new Error("No server connection")
+ return CronClient.cancelRun(conn, props.runID)
+ },
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ["cron"] }),
+ onError: (err: unknown) =>
+ showToast({
+ variant: "error",
+ title: language.t("common.requestFailed"),
+ description: err instanceof Error ? err.message : String(err),
+ }),
+ }))
+
+ const stopRun = (event: MouseEvent) => {
+ event.preventDefault()
+ event.stopPropagation()
+ cancelRun.mutate()
+ }
const indicator = (
@@ -305,12 +335,19 @@ const CronSessionRow = (props: {
>
{inner}
}
+ fallback={{inner}
}
>
-
+
{inner}
+
)
}
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index b591c0274..798ce19b0 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -42,6 +42,7 @@ import { usePermission } from "@/context/permission"
import { usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk"
import { useSettings } from "@/context/settings"
+import { useServer } from "@/context/server"
import { INITIAL_MESSAGE_PAGE_SIZE, useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal"
import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit"
@@ -73,6 +74,8 @@ import { Persist, persisted } from "@/utils/persist"
import { extractPromptFromParts } from "@/utils/prompt"
import { same } from "@/utils/same"
import { formatServerError } from "@/utils/server-errors"
+import { CronClient } from "@/utils/cron-client"
+import { canShowCronSessionStop } from "./cron-stop"
const emptyUserMessages: UserMessage[] = []
@@ -418,6 +421,7 @@ export default function Page() {
const language = useLanguage()
const permission = usePermission()
const sdk = useSDK()
+ const server = useServer()
const settings = useSettings()
const prompt = usePrompt()
const comments = useComments()
@@ -523,7 +527,8 @@ export default function Page() {
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const isChildSession = createMemo(() => !!info()?.parentID)
- const isCronSession = createMemo(() => !!(info() as { cronRunID?: string } | undefined)?.cronRunID)
+ const cronRunID = createMemo(() => (info() as { cronRunID?: string } | undefined)?.cronRunID)
+ const isCronSession = createMemo(() => !!cronRunID())
// Cron-driven sessions are read-only: the agent runs autonomously, no prompts
// or follow-ups accepted. We piggy-back on the existing `archived` predicate
// which already gates input, follow-ups, revert, and queue actions.
@@ -1765,6 +1770,27 @@ export default function Page() {
return isSessionWorking(sync.data.session_status[sessionID], sync.data.message[sessionID])
}
+ const stopCronRun = useMutation(() => ({
+ mutationFn: async (runID: string) => {
+ const conn = server.current?.http
+ if (!conn) throw new Error("No server connection")
+ return CronClient.cancelRun(conn, runID)
+ },
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ["cron"] }),
+ onError: fail,
+ }))
+
+ const cronStop = createMemo(() => {
+ const id = params.id
+ const runID = cronRunID()
+ if (!id || !runID) return
+ if (!canShowCronSessionStop({ sessionID: id, runID, busy: !!id && busy(id) })) return
+ return {
+ stopping: stopCronRun.isPending,
+ onStop: () => stopCronRun.mutate(runID),
+ }
+ })
+
// Server-authoritative queue: rows come from `sync.data.prompt_queue` —
// populated by the snapshot fetch below and kept fresh by the
// session.queue.{created,updated,removed} bus events. We display
@@ -2414,6 +2440,7 @@ export default function Page() {
}
: undefined
}
+ cronStop={cronStop()}
setPromptDockRef={(el) => {
promptDock = el
}}
diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx
index e89f7f1cd..e45bb89c7 100644
--- a/packages/app/src/pages/session/composer/session-composer-region.tsx
+++ b/packages/app/src/pages/session/composer/session-composer-region.tsx
@@ -8,6 +8,7 @@ import { useLanguage } from "@/context/language"
import { usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
+import { CronSessionStopAction } from "@/pages/cron-stop-actions"
import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
import { useSessionKey } from "@/pages/session/session-layout"
import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock"
@@ -52,6 +53,10 @@ export function SessionComposerRegion(props: {
disabled?: boolean
onRestore: (id: string) => void
}
+ cronStop?: {
+ stopping: boolean
+ onStop: () => void
+ }
setPromptDockRef: (el: HTMLDivElement) => void
}) {
const navigate = useNavigate()
@@ -68,6 +73,7 @@ export function SessionComposerRegion(props: {
const isCronSession = createMemo(() => !!(info() as { cronRunID?: string } | undefined)?.cronRunID)
const archived = createMemo(() => !!info()?.time.archived || isCronSession())
const showComposer = createMemo(() => !archived() && (!props.state.blocked() || child()))
+ const cronStop = createMemo(() => (isCronSession() ? props.cronStop : undefined))
const previewPrompt = () =>
prompt
@@ -326,9 +332,15 @@ export function SessionComposerRegion(props: {
class="flex w-full items-center gap-2 rounded-[12px] border border-border-weak-base bg-background-base px-4 py-3 text-14-regular text-text-weak"
>
-
+
{isCronSession() ? language.t("session.cron.readonly") : language.t("session.archived.readOnly")}
+ cronStop()?.onStop()}
+ />
diff --git a/packages/app/src/utils/cron-client.test.ts b/packages/app/src/utils/cron-client.test.ts
new file mode 100644
index 000000000..999c038ba
--- /dev/null
+++ b/packages/app/src/utils/cron-client.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, test } from "bun:test"
+import type { ServerConnection } from "@/context/server"
+import { CronClient } from "./cron-client"
+
+const server: ServerConnection.HttpBase = {
+ url: "http://localhost:4096",
+}
+
+describe("CronClient.cancelRun", () => {
+ test("posts to the cancel endpoint for one run", async () => {
+ let request: Request | undefined
+ const originalFetch = globalThis.fetch
+ globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
+ request = new Request(input, init)
+ return new Response(JSON.stringify(true), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ })
+ }) as unknown as typeof globalThis.fetch
+
+ try {
+ const result = await CronClient.cancelRun(server, "run/id with spaces")
+
+ expect(result).toBe(true)
+ expect(request?.method).toBe("POST")
+ expect(request?.url).toBe("http://localhost:4096/global/cron/runs/run%2Fid%20with%20spaces/cancel")
+ } finally {
+ globalThis.fetch = originalFetch
+ }
+ })
+})
diff --git a/site/app/docs/api/page.tsx b/site/app/docs/api/page.tsx
index 1abea170a..168cb76cf 100644
--- a/site/app/docs/api/page.tsx
+++ b/site/app/docs/api/page.tsx
@@ -49,6 +49,8 @@ export default function API() {
| GET | /global/version | Current/latest version, update state, detected install method. |
| GET | /global/event | Cross-instance SSE stream for global app state. |
| GET | /global/cron | List configured recurring jobs. |
+ | GET | /global/cron/:taskID/runs | List recent scheduled task runs. |
+ | POST | /global/cron/runs/:runID/cancel | Cancel a queued or running scheduled task run. |