From 1f4e6301fb13641691d3a82bfb299e198f10b1fb Mon Sep 17 00:00:00 2001 From: Codeplane Agent Date: Mon, 1 Jun 2026 12:48:12 +0000 Subject: [PATCH] fix(app): add stop button for queued/running scheduled task runs across web UI Adds cancel controls to three surfaces: - Cron task list (stop button for queued/running last runs) - Shared sidebar cron rail (hover/focus stop, always-visible on mobile) - Archived cron session dock in session view Also wires the stop mutation from the session page, extracts shared stop-action models for testability, and updates the API docs to list the new cron runs and cancel endpoints. Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: codeplane-agent[bot] <287208015+codeplane-agent[bot]@users.noreply.github.com> --- .../app/src/pages/cron-stop-actions.test.ts | 65 ++++++++++++++ packages/app/src/pages/cron-stop-actions.tsx | 90 +++++++++++++++++++ packages/app/src/pages/cron-stop-model.ts | 38 ++++++++ packages/app/src/pages/cron-stop.test.ts | 34 +++++++ packages/app/src/pages/cron-stop.ts | 15 ++++ packages/app/src/pages/cron.tsx | 23 +++++ .../app/src/pages/layout/sidebar-cron.tsx | 47 ++++++++-- packages/app/src/pages/session.tsx | 29 +++++- .../composer/session-composer-region.tsx | 14 ++- packages/app/src/utils/cron-client.test.ts | 31 +++++++ site/app/docs/api/page.tsx | 2 + 11 files changed, 381 insertions(+), 7 deletions(-) create mode 100644 packages/app/src/pages/cron-stop-actions.test.ts create mode 100644 packages/app/src/pages/cron-stop-actions.tsx create mode 100644 packages/app/src/pages/cron-stop-model.ts create mode 100644 packages/app/src/pages/cron-stop.test.ts create mode 100644 packages/app/src/pages/cron-stop.ts create mode 100644 packages/app/src/utils/cron-client.test.ts 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 0000000000..8691936adc --- /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 0000000000..04e9ec65f1 --- /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 0000000000..8c5ca08682 --- /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 0000000000..7e18d0cf05 --- /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 0000000000..9e06eef3bf --- /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 14243a8af2..8ddc537eb6 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 b591c02748..798ce19b06 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 e89f7f1cda..e45bb89c72 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 0000000000..999c038ba6 --- /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 1abea170ac..168cb76cf3 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/versionCurrent/latest version, update state, detected install method. GET/global/eventCross-instance SSE stream for global app state. GET/global/cronList configured recurring jobs. + GET/global/cron/:taskID/runsList recent scheduled task runs. + POST/global/cron/runs/:runID/cancelCancel a queued or running scheduled task run.