feat: scheduled tasks ux improvement (#300)

* feat: show scheduled tasks tab if job runs are empty

* chore: switch tabs after creating new tasks

* feat: provide option to cancel and retry scheduled tasks

* feat: provide option to retry and cancel jobs on the popups

* chore: fix minor race condition between window cleanup and job status
update
This commit is contained in:
Dani Akash
2026-02-04 16:37:12 +05:30
committed by GitHub
parent 33452715ba
commit e7ab1b6b6d
11 changed files with 269 additions and 36 deletions

View File

@@ -6,6 +6,8 @@ import {
CheckCircle2,
Copy,
Loader2,
RotateCcw,
Square,
XCircle,
} from 'lucide-react'
import { type FC, useState } from 'react'
@@ -27,6 +29,8 @@ interface RunResultDialogProps {
run: ScheduledJobRun | null
jobName?: string
onOpenChange: (open: boolean) => void
onCancelRun?: (runId: string) => void
onRetryRun?: (jobId: string) => void
}
const formatDateTime = (dateStr: string) =>
@@ -46,6 +50,8 @@ export const RunResultDialog: FC<RunResultDialogProps> = ({
run,
jobName,
onOpenChange,
onCancelRun,
onRetryRun,
}) => {
const [copied, setCopied] = useState(false)
@@ -99,6 +105,24 @@ export const RunResultDialog: FC<RunResultDialogProps> = ({
</ScrollArea>
<DialogFooter>
{run.status === 'running' && onCancelRun && (
<Button variant="destructive" onClick={() => onCancelRun(run.id)}>
<Square className="h-4 w-4" />
Cancel
</Button>
)}
{run.status === 'failed' && onRetryRun && (
<Button
variant="outline"
onClick={() => {
onRetryRun(run.jobId)
onOpenChange(false)
}}
>
<RotateCcw className="h-4 w-4" />
Retry
</Button>
)}
{run.result && (
<Button
variant="outline"

View File

@@ -7,6 +7,8 @@ import {
Loader2,
Pencil,
Play,
RotateCcw,
Square,
Trash2,
XCircle,
} from 'lucide-react'
@@ -31,6 +33,8 @@ interface ScheduledTaskCardProps {
onToggle: (enabled: boolean) => void
onRun: () => void
onViewRun: (run: ScheduledJobRun) => void
onCancelRun: (runId: string) => void
onRetryRun: (jobId: string) => void
}
function formatSchedule(job: ScheduledJob): string {
@@ -72,6 +76,8 @@ export const ScheduledTaskCard: FC<ScheduledTaskCardProps> = ({
onToggle,
onRun,
onViewRun,
onCancelRun,
onRetryRun,
}) => {
const [isOpen, setIsOpen] = useState(false)
@@ -180,14 +186,44 @@ export const ScheduledTaskCard: FC<ScheduledTaskCardProps> = ({
</p>
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => onViewRun(run)}
className="shrink-0 text-muted-foreground hover:text-foreground"
>
View
</Button>
<div className="flex shrink-0 items-center gap-1">
{run.status === 'running' && (
<Button
variant="ghost"
size="icon-sm"
onClick={(e) => {
e.stopPropagation()
onCancelRun(run.id)
}}
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
aria-label="Cancel run"
>
<Square className="h-3.5 w-3.5" />
</Button>
)}
{run.status === 'failed' && (
<Button
variant="ghost"
size="icon-sm"
onClick={(e) => {
e.stopPropagation()
onRetryRun(run.jobId)
}}
className="text-muted-foreground hover:text-foreground"
aria-label="Retry run"
>
<RotateCcw className="h-3.5 w-3.5" />
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => onViewRun(run)}
className="shrink-0 text-muted-foreground hover:text-foreground"
>
View
</Button>
</div>
</div>
))}
</div>

View File

@@ -1,6 +1,14 @@
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { Calendar, CheckCircle2, Clock, Loader2, XCircle } from 'lucide-react'
import {
Calendar,
CheckCircle2,
Clock,
Loader2,
RotateCcw,
Square,
XCircle,
} from 'lucide-react'
import type { FC } from 'react'
import { useMemo } from 'react'
import { Button } from '@/components/ui/button'
@@ -21,6 +29,8 @@ interface JobRunWithDetails extends ScheduledJobRun {
interface ScheduledTaskResultsProps {
onViewRun: (run: ScheduledJobRun) => void
onCancelRun: (runId: string) => void
onRetryRun: (jobId: string) => void
}
const getStatusIcon = (status: JobRunWithDetails['status']) => {
@@ -38,6 +48,8 @@ const formatTimestamp = (dateString: string) => dayjs(dateString).fromNow()
export const ScheduledTaskResults: FC<ScheduledTaskResultsProps> = ({
onViewRun,
onCancelRun,
onRetryRun,
}) => {
const { jobRuns } = useScheduledJobRuns()
const { jobs } = useScheduledJobs()
@@ -99,6 +111,34 @@ export const ScheduledTaskResults: FC<ScheduledTaskResultsProps> = ({
</p>
)}
</div>
{run.status === 'running' && (
<Button
variant="ghost"
size="icon-sm"
onClick={(e) => {
e.stopPropagation()
onCancelRun(run.id)
}}
className="shrink-0 text-destructive hover:bg-destructive/10 hover:text-destructive"
aria-label="Cancel run"
>
<Square className="h-3.5 w-3.5" />
</Button>
)}
{run.status === 'failed' && (
<Button
variant="ghost"
size="icon-sm"
onClick={(e) => {
e.stopPropagation()
onRetryRun(run.jobId)
}}
className="shrink-0 text-muted-foreground hover:text-foreground"
aria-label="Retry run"
>
<RotateCcw className="h-3.5 w-3.5" />
</Button>
)}
</div>
</Button>
))}

View File

@@ -9,6 +9,8 @@ interface ScheduledTasksListProps {
onToggle: (jobId: string, enabled: boolean) => void
onRun: (jobId: string) => void
onViewRun: (run: ScheduledJobRun) => void
onCancelRun: (runId: string) => void
onRetryRun: (jobId: string) => void
}
export const ScheduledTasksList: FC<ScheduledTasksListProps> = ({
@@ -18,6 +20,8 @@ export const ScheduledTasksList: FC<ScheduledTasksListProps> = ({
onToggle,
onRun,
onViewRun,
onCancelRun,
onRetryRun,
}) => {
if (jobs.length === 0) {
return (
@@ -42,6 +46,8 @@ export const ScheduledTasksList: FC<ScheduledTasksListProps> = ({
onToggle={(enabled) => onToggle(job.id, enabled)}
onRun={() => onRun(job.id)}
onViewRun={onViewRun}
onCancelRun={onCancelRun}
onRetryRun={onRetryRun}
/>
))}
</div>

View File

@@ -1,4 +1,4 @@
import { type FC, useState } from 'react'
import { type FC, useEffect, useState } from 'react'
import { RunResultDialog } from '@/components/ai-elements/run-result-dialog'
import {
AlertDialog,
@@ -13,8 +13,10 @@ import {
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
NEW_SCHEDULED_TASK_CREATED_EVENT,
SCHEDULED_TASK_CANCELLED_EVENT,
SCHEDULED_TASK_DELETED_EVENT,
SCHEDULED_TASK_EDITED_EVENT,
SCHEDULED_TASK_RETRIED_EVENT,
SCHEDULED_TASK_TESTED_EVENT,
SCHEDULED_TASK_TOGGLED_EVENT,
SCHEDULED_TASK_VIEW_RESULTS_EVENT,
@@ -22,7 +24,11 @@ import {
import { useGraphqlMutation } from '@/lib/graphql/useGraphqlMutation'
import { track } from '@/lib/metrics/track'
import { DeleteScheduledJobDocument } from '@/lib/schedules/graphql/syncSchedulesDocument'
import { useScheduledJobs } from '@/lib/schedules/scheduleStorage'
import {
scheduledJobRunStorage,
useScheduledJobRuns,
useScheduledJobs,
} from '@/lib/schedules/scheduleStorage'
import type { ScheduledJobRun } from '@/lib/schedules/scheduleTypes'
import { NewScheduledTaskDialog } from './NewScheduledTaskDialog'
import { ScheduledTaskResults } from './ScheduledTaskResults'
@@ -37,9 +43,11 @@ import type { ScheduledJob } from './types'
export const ScheduledTasksPage: FC = () => {
const { jobs, addJob, editJob, toggleJob, removeJob, runJob } =
useScheduledJobs()
const { cancelJobRun } = useScheduledJobRuns()
const deleteRemoteJobMutation = useGraphqlMutation(DeleteScheduledJobDocument)
const [activeTab, setActiveTab] = useState<string | null>(null)
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [editingJob, setEditingJob] = useState<ScheduledJob | null>(null)
const [deleteJobId, setDeleteJobId] = useState<string | null>(null)
@@ -80,6 +88,7 @@ export const ScheduledTasksPage: FC = () => {
})
} else {
await addJob(data)
setActiveTab('tasks')
track(NEW_SCHEDULED_TASK_CREATED_EVENT, {
scheduleType: data.scheduleType,
interval: data.scheduleInterval,
@@ -98,11 +107,27 @@ export const ScheduledTasksPage: FC = () => {
track(SCHEDULED_TASK_TESTED_EVENT)
}
const handleCancelRun = async (runId: string) => {
await cancelJobRun(runId)
track(SCHEDULED_TASK_CANCELLED_EVENT)
}
const handleRetryRun = async (jobId: string) => {
await runJob(jobId)
track(SCHEDULED_TASK_RETRIED_EVENT)
}
const handleViewRun = (run: ScheduledJobRun) => {
setViewingRun(run)
track(SCHEDULED_TASK_VIEW_RESULTS_EVENT)
}
useEffect(() => {
scheduledJobRunStorage.getValue().then((runs) => {
setActiveTab(runs && runs.length > 0 ? 'results' : 'tasks')
})
}, [])
const jobToDelete = deleteJobId
? jobs.find((j) => j.id === deleteJobId)
: null
@@ -111,27 +136,35 @@ export const ScheduledTasksPage: FC = () => {
<div className="fade-in slide-in-from-bottom-5 animate-in space-y-6 duration-500">
<ScheduledTasksHeader onAddClick={handleAdd} />
<Tabs defaultValue="results">
<TabsList>
<TabsTrigger value="results">Results</TabsTrigger>
<TabsTrigger value="tasks">Scheduled Tasks</TabsTrigger>
</TabsList>
{activeTab && (
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="results">Results</TabsTrigger>
<TabsTrigger value="tasks">Scheduled Tasks</TabsTrigger>
</TabsList>
<TabsContent value="results">
<ScheduledTaskResults onViewRun={handleViewRun} />
</TabsContent>
<TabsContent value="results">
<ScheduledTaskResults
onViewRun={handleViewRun}
onCancelRun={handleCancelRun}
onRetryRun={handleRetryRun}
/>
</TabsContent>
<TabsContent value="tasks">
<ScheduledTasksList
jobs={jobs}
onEdit={handleEdit}
onDelete={handleDelete}
onToggle={handleToggle}
onRun={handleRun}
onViewRun={handleViewRun}
/>
</TabsContent>
</Tabs>
<TabsContent value="tasks">
<ScheduledTasksList
jobs={jobs}
onEdit={handleEdit}
onDelete={handleDelete}
onToggle={handleToggle}
onRun={handleRun}
onViewRun={handleViewRun}
onCancelRun={handleCancelRun}
onRetryRun={handleRetryRun}
/>
</TabsContent>
</Tabs>
)}
<NewScheduledTaskDialog
open={isDialogOpen}
@@ -148,6 +181,11 @@ export const ScheduledTasksPage: FC = () => {
: undefined
}
onOpenChange={(open) => !open && setViewingRun(null)}
onCancelRun={handleCancelRun}
onRetryRun={(jobId) => {
handleRetryRun(jobId)
setViewingRun(null)
}}
/>
<AlertDialog

View File

@@ -11,6 +11,8 @@ const MAX_RUNS_PER_JOB = 15
const STALE_TIMEOUT_MS = 10 * 60 * 1000 // 10 minutes
const TWENTY_FOUR_HOURS_MS = 24 * 60 * 60 * 1000
const runAbortControllers = new Map<string, AbortController>()
export const scheduledJobRuns = async () => {
const cleanupStaleJobRuns = async () => {
const current = (await scheduledJobRunStorage.getValue()) ?? []
@@ -127,12 +129,15 @@ export const scheduledJobRuns = async () => {
}
const jobRun = await createJobRun(jobId, 'running')
const abortController = new AbortController()
runAbortControllers.set(jobRun.id, abortController)
try {
const response = await getChatServerResponse({
message: job.query,
activeTab: backgroundTab,
windowId: backgroundWindow.id,
signal: abortController.signal,
})
await updateJobRun(jobRun.id, {
@@ -144,7 +149,12 @@ export const scheduledJobRuns = async () => {
toolCalls: response.toolCalls,
})
} catch (e) {
const errorMessage = e instanceof Error ? e.message : String(e)
const isCancelled = abortController.signal.aborted
const errorMessage = isCancelled
? 'Cancelled by user'
: e instanceof Error
? e.message
: String(e)
await updateJobRun(jobRun.id, {
status: 'failed',
completedAt: new Date().toISOString(),
@@ -152,10 +162,15 @@ export const scheduledJobRuns = async () => {
error: errorMessage,
})
} finally {
await updateJobLastRunAt(jobId)
runAbortControllers.delete(jobRun.id)
if (backgroundWindow.id) {
await chrome.windows.remove(backgroundWindow.id)
try {
await chrome.windows.remove(backgroundWindow.id)
} catch {
// Window may already be closed
}
}
await updateJobLastRunAt(jobId)
}
}
@@ -228,6 +243,15 @@ export const scheduledJobRuns = async () => {
}
})
onScheduleMessage('cancelScheduledJobRun', async ({ data }) => {
const controller = runAbortControllers.get(data.runId)
if (!controller) {
return { success: false, error: 'Run not found or already completed' }
}
controller.abort()
return { success: true }
})
chrome.runtime.onStartup.addListener(async () => {
await cleanupStaleJobRuns()
await syncAlarmState()

View File

@@ -6,6 +6,8 @@ import {
ChevronDown,
Clock,
Loader2,
RotateCcw,
Square,
XCircle,
} from 'lucide-react'
import type { FC } from 'react'
@@ -19,6 +21,8 @@ import {
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
SCHEDULED_TASK_CANCELLED_EVENT,
SCHEDULED_TASK_RETRIED_EVENT,
SCHEDULED_TASK_VIEW_MORE_IN_NEWTAB_EVENT,
SCHEDULED_TASK_VIEW_RESULTS_IN_NEWTAB_EVENT,
} from '@/lib/constants/analyticsEvents'
@@ -66,8 +70,8 @@ export const ScheduleResults: FC = () => {
localStorage.setItem(SCHEDULE_RESULTS_COLLAPSED_KEY, (!open).toString())
}
const { jobRuns } = useScheduledJobRuns()
const { jobs } = useScheduledJobs()
const { jobRuns, cancelJobRun } = useScheduledJobRuns()
const { jobs, runJob } = useScheduledJobs()
const runningCount = jobRuns.filter((r) => r.status === 'running').length
@@ -102,6 +106,17 @@ export const ScheduleResults: FC = () => {
setViewingRun(run)
}
const handleCancelRun = async (runId: string) => {
await cancelJobRun(runId)
track(SCHEDULED_TASK_CANCELLED_EVENT)
}
const handleRetryRun = async (jobId: string) => {
await runJob(jobId)
setViewingRun(null)
track(SCHEDULED_TASK_RETRIED_EVENT)
}
if (!displayedRuns.length) return null
return (
@@ -158,6 +173,34 @@ export const ScheduleResults: FC = () => {
</p>
)}
</div>
{run.status === 'running' && (
<Button
variant="ghost"
size="icon-sm"
onClick={(e) => {
e.stopPropagation()
handleCancelRun(run.id)
}}
className="shrink-0 text-destructive hover:bg-destructive/10 hover:text-destructive"
aria-label="Cancel run"
>
<Square className="h-3.5 w-3.5" />
</Button>
)}
{run.status === 'failed' && (
<Button
variant="ghost"
size="icon-sm"
onClick={(e) => {
e.stopPropagation()
handleRetryRun(run.jobId)
}}
className="shrink-0 text-muted-foreground hover:text-foreground"
aria-label="Retry run"
>
<RotateCcw className="h-3.5 w-3.5" />
</Button>
)}
</div>
</Button>
))}
@@ -176,6 +219,8 @@ export const ScheduleResults: FC = () => {
run={viewingRun}
jobName={viewingRun?.job?.name}
onOpenChange={(open) => !open && setViewingRun(null)}
onCancelRun={handleCancelRun}
onRetryRun={handleRetryRun}
/>
</Collapsible>
)

View File

@@ -145,5 +145,12 @@ export const JTBD_POPUP_SHOWN_EVENT = 'ui.jtbd_popup.shown'
/** @public */
export const JTBD_POPUP_CLICKED_EVENT = 'ui.jtbd_popup.clicked'
/** @public */
export const SCHEDULED_TASK_CANCELLED_EVENT =
'settings.scheduled_task.cancelled'
/** @public */
export const SCHEDULED_TASK_RETRIED_EVENT = 'settings.scheduled_task.retried'
/** @public */
export const JTBD_POPUP_DISMISSED_EVENT = 'ui.jtbd_popup.dismissed'

View File

@@ -4,6 +4,10 @@ interface RunScheduledJobData {
jobId: string
}
interface CancelScheduledJobRunData {
runId: string
}
interface RunScheduledJobResponse {
success: boolean
error?: string
@@ -11,6 +15,9 @@ interface RunScheduledJobResponse {
type ScheduleMessagesProtocol = {
runScheduledJob(data: RunScheduledJobData): RunScheduledJobResponse
cancelScheduledJobRun(
data: CancelScheduledJobRunData,
): RunScheduledJobResponse
}
const { sendMessage, onMessage } =

View File

@@ -23,6 +23,7 @@ interface ChatServerRequest {
conversationId?: string
windowId?: number
activeTab?: ActiveTab
signal?: AbortSignal
}
interface ChatServerResponse {
@@ -93,6 +94,7 @@ export async function getChatServerResponse(
const response = await fetch(`${agentServerUrl}/chat`, {
method: 'POST',
signal: request.signal,
headers: {
'Content-Type': 'application/json',
},

View File

@@ -142,7 +142,11 @@ export function useScheduledJobRuns() {
)
}
return { jobRuns, addJobRun, removeJobRun, editJobRun }
const cancelJobRun = async (runId: string) => {
return sendScheduleMessage('cancelScheduledJobRun', { runId })
}
return { jobRuns, addJobRun, removeJobRun, editJobRun, cancelJobRun }
}
export async function syncScheduledJobs(): Promise<void> {