Skip to content
This repository was archived by the owner on Jun 1, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
65 changes: 65 additions & 0 deletions packages/app/src/pages/cron-stop-actions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { describe, expect, test } from "bun:test"
import {
getCronSessionStopAction,
getCronSidebarStopAction,
getCronTaskStopAction,
} from "./cron-stop-model"
Comment on lines +1 to +6

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)
})
})
90 changes: 90 additions & 0 deletions packages/app/src/pages/cron-stop-actions.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Show when={action} keyed>
{(action) => (
<Tooltip value={props.label} placement="top">
<IconButton
icon="stop"
variant="ghost"
size="normal"
aria-label={action.label}
disabled={action.disabled}
onClick={action.onClick}
/>
</Tooltip>
)}
</Show>
)
}

export function CronSidebarStopAction(props: {
status: CronRunStatus
label: string
disabled?: boolean
mobile?: boolean
onClick: StopClick
}) {
const action = getCronSidebarStopAction(props)
return (
<Show when={action} keyed>
{(action) => (
<div class={action.class}>
<Tooltip value={action.label} placement="top">
<IconButton
icon="stop"
variant="ghost"
size="small"
aria-label={action.label}
disabled={action.disabled}
onClick={action.onClick}
/>
</Tooltip>
</div>
)}
</Show>
)
}

export function CronSessionStopAction(props: {
visible?: boolean
label: string
stopping?: boolean
onClick: StopClick
}) {
const action = getCronSessionStopAction(props)
return (
<Show when={action} keyed>
{(action) => (
<Button
type="button"
variant="secondary"
size="small"
icon="stop"
disabled={action.stopping}
onClick={action.onClick}
class="shrink-0"
>
{action.label}
</Button>
)}
</Show>
)
}
38 changes: 38 additions & 0 deletions packages/app/src/pages/cron-stop-model.ts
Original file line number Diff line number Diff line change
@@ -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
}
34 changes: 34 additions & 0 deletions packages/app/src/pages/cron-stop.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
15 changes: 15 additions & 0 deletions packages/app/src/pages/cron-stop.ts
Original file line number Diff line number Diff line change
@@ -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
23 changes: 23 additions & 0 deletions packages/app/src/pages/cron.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
} 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"

Expand Down Expand Up @@ -57,7 +58,7 @@
return `${language.t("cron.field.schedule.interval")}: ${mins}`
}

function statusClass(status: CronStatus | CronRunStatus): string {

Check warning on line 61 in packages/app/src/pages/cron.tsx

View workflow job for this annotation

GitHub Actions / lint

eslint(no-unused-vars)

Function 'statusClass' is declared but never used.
switch (status) {
case "active":
case "success":
Expand Down Expand Up @@ -268,6 +269,22 @@
}),
}))

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
Expand Down Expand Up @@ -322,6 +339,12 @@
</Show>
</div>
<div class="flex shrink-0 items-center gap-1">
<CronTaskStopAction
status={props.task.lastRunStatus}
label={language.t("cron.action.cancel")}
disabled={cancelRun.isPending || !props.task.lastRunID}
onClick={() => cancelRun.mutate()}
/>
<Tooltip value={language.t("cron.action.trigger")} placement="top">
<IconButton
icon="arrow-right"
Expand Down
Loading
Loading