feat: scheduled tasks (#146)

* feat: scheduled tasks base ui

* chore: fix biome version

* fix: type issues

* chore: remove use callback

* chore: refactor scheduleStorage types

* feat: create storage hooks for job & job runs

* feat: integrate listing with store

* feat: schedule tasks dialog integration

* feat: integrate view and runs

* feat: sync alarm state

* fix: check for enabled jobs in alarm state

* feat: createAlarmFromJob utility

* feat: updated edit hooks to update alarms

* feat: getChatServerResponse util

* feat: run jobs in schedule

* feat: update job run stat with storage

* feat: discard old runs over 15

* feat: provide graph mode entry

* feat: footer link with scheduler option

* feat: use a nicer loader for task runs

* feat: schedule results component

* feat: scheduler results in new tab page

* feat: nicer date formatting with dayjs

* feat: use run-result-dialog for displaying run results in new tab

* chore: delete mocked storage methods

* chore: remove unused code

* chore: remove all job runs when a job is deleted

* feat: use shadcn elements for schedule results component

* feat: render results in markdown view

* chore: added important update on logic sharing

* chore: remove loading state in scheduledtaskslist

* feat: run the background job in a unfocused window

* feat: provide mcp options to the background scheduled tasks

* chore: clean up stale jobs on chrome restart or update

* fix: background window not cleaned up on error

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* chore: fix type issues

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
Dani Akash
2026-01-03 00:08:51 +05:30
committed by GitHub
parent ee5de61967
commit c4dac9380b
24 changed files with 1591 additions and 76 deletions

View File

@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.9/schema.json",
"$schema": "https://biomejs.dev/schemas/2.3.10/schema.json",
"root": false,
"extends": "//",
"vcs": {

View File

@@ -0,0 +1,120 @@
import dayjs from 'dayjs'
import duration from 'dayjs/plugin/duration'
import DOMPurify from 'dompurify'
import { Check, CheckCircle2, Copy, Loader2, XCircle } from 'lucide-react'
import { marked } from 'marked'
import { type FC, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { ScrollArea } from '@/components/ui/scroll-area'
import type { ScheduledJobRun } from '@/lib/schedules/scheduleTypes'
dayjs.extend(duration)
interface RunResultDialogProps {
run: ScheduledJobRun | null
jobName?: string
onOpenChange: (open: boolean) => void
}
const formatDateTime = (dateStr: string) =>
dayjs(dateStr).format('MMM D, YYYY, h:mm A')
function formatDuration(startedAt: string, completedAt?: string): string {
if (!completedAt) return 'Still running'
const diff = dayjs(completedAt).diff(dayjs(startedAt))
const d = dayjs.duration(diff)
const mins = Math.floor(d.asMinutes())
const secs = d.seconds()
if (mins === 0) return `${secs} seconds`
return `${mins}m ${secs}s`
}
export const RunResultDialog: FC<RunResultDialogProps> = ({
run,
jobName,
onOpenChange,
}) => {
const [copied, setCopied] = useState(false)
const renderedContent = useMemo(() => {
if (!run?.result) return null
const html = marked.parse(run.result, { async: false }) as string
return DOMPurify.sanitize(html)
}, [run?.result])
const handleCopy = async () => {
if (!run?.result) return
await navigator.clipboard.writeText(run.result)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
if (!run) return null
return (
<Dialog open={!!run} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{run.status === 'completed' ? (
<CheckCircle2 className="h-5 w-5 text-green-500" />
) : run.status === 'failed' ? (
<XCircle className="h-5 w-5 text-destructive" />
) : (
<Loader2 className="h-5 w-5 animate-spin text-accent-orange" />
)}
{jobName || 'Run Result'}
</DialogTitle>
<div className="text-muted-foreground text-sm">
{formatDateTime(run.startedAt)} {' '}
{formatDuration(run.startedAt, run.completedAt)}
</div>
</DialogHeader>
<ScrollArea className="max-h-100">
{renderedContent ? (
<div
className="prose prose-sm dark:prose-invert max-w-none rounded-lg border border-border bg-muted/50 p-4"
// biome-ignore lint/security/noDangerouslySetInnerHtml: renderedContent is sanitized with DOMPurify
dangerouslySetInnerHTML={{ __html: renderedContent }}
/>
) : (
<div className="rounded-lg border border-border bg-muted/50 p-4 text-muted-foreground text-sm">
No result available
</div>
)}
</ScrollArea>
<DialogFooter>
{run.result && (
<Button
variant="outline"
onClick={handleCopy}
className="mr-2 sm:mr-0"
>
{copied ? (
<>
<Check className="h-4 w-4" />
Copied
</>
) : (
<>
<Copy className="h-4 w-4" />
Copy
</>
)}
</Button>
)}
<Button onClick={() => onOpenChange(false)}>Close</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -6,13 +6,16 @@ import { fetchMcpTools } from '@/lib/mcp/client'
import { onServerMessage } from '@/lib/messaging/server/serverMessages'
import { onOpenSidePanelWithSearch } from '@/lib/messaging/sidepanel/openSidepanelWithSearch'
import { searchActionsStorage } from '@/lib/search-actions/searchActionsStorage'
import { scheduledJobRuns } from './scheduledJobRuns'
export default defineBackground(async () => {
export default defineBackground(() => {
chrome.sidePanel.setOptions({ enabled: false })
await Capabilities.initialize()
Capabilities.initialize().catch(() => null)
setupLlmProvidersBackupToBrowserOS()
scheduledJobRuns()
chrome.action.onClicked.addListener(async (tab) => {
if (tab.id) {
await toggleSidePanel(tab.id)

View File

@@ -0,0 +1,158 @@
import { createAlarmFromJob } from '@/lib/schedules/createAlarmFromJob'
import { getChatServerResponse } from '@/lib/schedules/getChatServerResponse'
import {
scheduledJobRunStorage,
scheduledJobStorage,
} from '@/lib/schedules/scheduleStorage'
import type { ScheduledJobRun } from '@/lib/schedules/scheduleTypes'
const MAX_RUNS_PER_JOB = 15
const STALE_TIMEOUT_MS = 10 * 60 * 1000 // 10 minutes
export const scheduledJobRuns = async () => {
const cleanupStaleJobRuns = async () => {
const current = (await scheduledJobRunStorage.getValue()) ?? []
const now = Date.now()
const updated = current.map((run) => {
if (run.status !== 'running') return run
const startedAt = new Date(run.startedAt).getTime()
if (now - startedAt > STALE_TIMEOUT_MS) {
return {
...run,
status: 'failed' as const,
completedAt: new Date().toISOString(),
result: 'Job timed out!',
}
}
return run
})
await scheduledJobRunStorage.setValue(updated)
}
const syncAlarmState = async () => {
const jobs = (await scheduledJobStorage.getValue()).filter(
(each) => each.enabled,
)
for (let i = 0; i < jobs.length; i++) {
const job = jobs[i]
const alarmName = `scheduled-job-${job.id}`
const existingAlarm = await chrome.alarms.get(alarmName)
if (!existingAlarm) {
await createAlarmFromJob(job)
}
}
}
const createJobRun = async (
jobId: string,
status: ScheduledJobRun['status'],
): Promise<ScheduledJobRun> => {
const jobRun: ScheduledJobRun = {
id: crypto.randomUUID(),
jobId,
startedAt: new Date().toISOString(),
status,
}
const current = (await scheduledJobRunStorage.getValue()) ?? []
const otherJobRuns = current.filter((r) => r.jobId !== jobId)
const thisJobRuns = current
.filter((r) => r.jobId === jobId)
.sort(
(a, b) =>
new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime(),
)
.slice(0, MAX_RUNS_PER_JOB - 1)
await scheduledJobRunStorage.setValue([
...otherJobRuns,
...thisJobRuns,
jobRun,
])
return jobRun
}
const updateJobRun = async (
runId: string,
updates: Partial<Omit<ScheduledJobRun, 'id' | 'jobId' | 'startedAt'>>,
) => {
const current = (await scheduledJobRunStorage.getValue()) ?? []
await scheduledJobRunStorage.setValue(
current.map((r) => (r.id === runId ? { ...r, ...updates } : r)),
)
}
const updateJobLastRunAt = async (jobId: string) => {
const current = (await scheduledJobStorage.getValue()) ?? []
await scheduledJobStorage.setValue(
current.map((j) =>
j.id === jobId ? { ...j, lastRunAt: new Date().toISOString() } : j,
),
)
}
chrome.alarms.onAlarm.addListener(async (alarm) => {
if (!alarm.name.startsWith('scheduled-job-')) return
const jobId = alarm.name.replace('scheduled-job-', '')
const job = (await scheduledJobStorage.getValue()).find(
(each) => each.id === jobId,
)
if (!job) return
const backgroundWindow = await chrome.windows.create({
url: 'chrome://newtab',
focused: false,
state: 'minimized',
type: 'normal',
})
const backgroundTab = backgroundWindow?.tabs?.[0]
if (!backgroundWindow || !backgroundTab) return
const jobRun = await createJobRun(jobId, 'running')
try {
const response = await getChatServerResponse({
message: job.query,
activeTab: backgroundTab,
windowId: backgroundWindow.id,
})
await updateJobRun(jobRun.id, {
status: 'completed',
completedAt: new Date().toISOString(),
result: response.text,
})
} catch (e) {
await updateJobRun(jobRun.id, {
status: 'failed',
completedAt: new Date().toISOString(),
result: e instanceof Error ? e.message : String(e),
})
} finally {
await updateJobLastRunAt(jobId)
if (backgroundWindow.id) {
await chrome.windows.remove(backgroundWindow.id)
}
}
})
chrome.runtime.onStartup.addListener(async () => {
await cleanupStaleJobRuns()
await syncAlarmState()
})
chrome.runtime.onInstalled.addListener(async () => {
await cleanupStaleJobRuns()
await syncAlarmState()
})
}

View File

@@ -0,0 +1,56 @@
import { Calendar } from 'lucide-react'
import type { FC } from 'react'
import { Button } from '@/components/ui/button'
import { Feature } from '@/lib/browseros/capabilities'
import { useCapabilities } from '@/lib/browseros/useCapabilities'
interface FooterLinksProps {
onOpenShortcuts: () => void
}
export const FooterLinks: FC<FooterLinksProps> = ({ onOpenShortcuts }) => {
const { supports } = useCapabilities()
return (
<div className="flex items-center justify-center gap-4 pt-4">
<a
href="/options.html#/scheduled"
className="group inline-flex flex-row gap-2 text-muted-foreground text-xs transition-colors hover:text-foreground"
>
<Calendar className="h-4 w-4 transition-colors group-hover:text-accent-orange" />
Scheduler{' '}
<span className="text-accent-orange group-hover:text-accent-orange-bright">
(new)
</span>
</a>
<span className="text-muted-foreground"></span>
{supports(Feature.MANAGED_MCP_SUPPORT) && (
<>
<a
href="/options.html#/connect-mcp"
className="text-muted-foreground text-xs transition-colors hover:text-foreground"
>
Connect MCP servers{' '}
</a>
<span className="text-muted-foreground"></span>
</>
)}
<a
href="/options.html"
className="text-muted-foreground text-xs transition-colors hover:text-foreground"
>
Settings
</a>
<span className="text-muted-foreground"></span>
<Button
variant="link"
onClick={onOpenShortcuts}
className="hover:no-underline! px-0! text-muted-foreground text-xs transition-colors hover:text-foreground"
>
Shortcuts
</Button>
</div>
)
}

View File

@@ -18,14 +18,13 @@ import {
import { TabSelector } from '@/components/elements/tab-selector'
import { ThemeToggle } from '@/components/elements/theme-toggle'
import { Button } from '@/components/ui/button'
import { Feature } from '@/lib/browseros/capabilities'
import { useCapabilities } from '@/lib/browseros/useCapabilities'
import {
createAITabAction,
createBrowserOSAction,
} from '@/lib/chat-actions/types'
import { openSidePanelWithSearch } from '@/lib/messaging/sidepanel/openSidepanelWithSearch'
import { cn } from '@/lib/utils'
import { FooterLinks } from './FooterLinks'
import type { SuggestionItem } from './lib/suggestions/types'
import {
getSuggestionLabel,
@@ -33,6 +32,7 @@ import {
} from './lib/suggestions/useSuggestions'
import { NewTabBranding } from './NewTabBranding'
import { NewTabFocusGrid } from './NewTabFocusGrid'
import { ScheduleResults } from './ScheduleResults'
import { SearchSuggestions } from './SearchSuggestions'
import { ShortcutsDialog } from './ShortcutsDialog'
import { TopSites } from './TopSites'
@@ -56,7 +56,6 @@ export const NewTab = () => {
const tabsDropdownRef = useRef<HTMLDivElement>(null)
const [selectedTabs, setSelectedTabs] = useState<chrome.tabs.Tab[]>([])
const [shortcutsDialogOpen, setShortcutsDialogOpen] = useState(false)
const { supports } = useCapabilities()
const toggleTab = (tab: chrome.tabs.Tab) => {
setSelectedTabs((prev) => {
@@ -419,53 +418,11 @@ export const NewTab = () => {
{/* Top sites */}
{!isSuggestionsVisible && <TopSites />}
{/* Footer links */}
{!isSuggestionsVisible && (
<div className="flex items-center justify-center gap-4 pt-4">
<a
href="/options.html"
className="text-muted-foreground text-xs transition-colors hover:text-foreground"
>
Settings
</a>
<span className="text-muted-foreground"></span>
<Button
variant="link"
onClick={() => setShortcutsDialogOpen(true)}
className="hover:no-underline! px-0! text-muted-foreground text-xs transition-colors hover:text-foreground"
>
Shortcuts
</Button>
{supports(Feature.MANAGED_MCP_SUPPORT) && (
<>
<span className="text-muted-foreground"></span>
<a
href="/options.html#/connect-mcp"
className="text-muted-foreground text-xs transition-colors hover:text-foreground"
>
Connect MCP servers{' '}
<span className="text-[var(--accent-orange)]">(new)</span>
</a>
</>
)}
{/*<span className="text-muted-foreground">•</span>
<a
href="/settings"
className="text-muted-foreground text-xs transition-colors hover:text-foreground"
>
Shortcuts
</a>
<span className="text-muted-foreground">•</span>
<a
href="/settings"
className="text-muted-foreground text-xs transition-colors hover:text-foreground"
>
Personalize
</a>*/}
</div>
<FooterLinks onOpenShortcuts={() => setShortcutsDialogOpen(true)} />
)}
{mounted && !isSuggestionsVisible && <ScheduleResults />}
</div>
{mounted && (
<ShortcutsDialog

View File

@@ -0,0 +1,152 @@
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import {
Calendar,
CheckCircle2,
ChevronDown,
Clock,
Loader2,
XCircle,
} from 'lucide-react'
import type { FC } from 'react'
import { useMemo, useState } from 'react'
import { RunResultDialog } from '@/components/ai-elements/run-result-dialog'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
useScheduledJobRuns,
useScheduledJobs,
} from '@/lib/schedules/scheduleStorage'
import type {
ScheduledJob,
ScheduledJobRun,
} from '@/lib/schedules/scheduleTypes'
dayjs.extend(relativeTime)
interface JobRunWithDetails extends ScheduledJobRun {
job: ScheduledJob | undefined
}
const MAX_DISPLAY_COUNT = 3
const getStatusIcon = (status: JobRunWithDetails['status']) => {
switch (status) {
case 'completed':
return <CheckCircle2 className="h-4 w-4 text-green-500" />
case 'running':
return <Loader2 className="h-4 w-4 animate-spin text-accent-orange" />
case 'failed':
return <XCircle className="h-4 w-4 text-destructive" />
}
}
const formatTimestamp = (dateString: string) => dayjs(dateString).fromNow()
export const ScheduleResults: FC = () => {
const [isOpen, setIsOpen] = useState(true)
const [viewingRun, setViewingRun] = useState<JobRunWithDetails | null>(null)
const { jobRuns } = useScheduledJobRuns()
const { jobs } = useScheduledJobs()
const runningCount = jobRuns.filter((r) => r.status === 'running').length
const displayedRuns: JobRunWithDetails[] = useMemo(() => {
const enrichWithJob = (run: ScheduledJobRun): JobRunWithDetails => ({
...run,
job: jobs.find((j) => j.id === run.jobId),
})
const runningJobs = jobRuns
.filter((r) => r.status === 'running')
.map(enrichWithJob)
if (runningJobs.length >= MAX_DISPLAY_COUNT) {
return runningJobs
}
const completedOrFailed = jobRuns
.filter((r) => r.status === 'completed' || r.status === 'failed')
.sort(
(a, b) =>
new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime(),
)
.slice(0, MAX_DISPLAY_COUNT - runningJobs.length)
.map(enrichWithJob)
return [...runningJobs, ...completedOrFailed]
}, [jobRuns, jobs])
return (
<Collapsible
open={isOpen}
onOpenChange={setIsOpen}
className="mb-16 space-y-3"
>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className="group flex h-auto w-full items-center justify-between rounded-xl border border-border/50 bg-card/50 p-3 transition-all hover:border-border hover:bg-card"
>
<div className="flex items-center gap-3">
<Calendar className="h-4 w-4 text-muted-foreground" />
<span className="font-medium text-foreground text-sm">
Scheduler Outputs
</span>
{runningCount > 0 && (
<Badge variant="secondary" className="text-xs">
{runningCount} running
</Badge>
)}
</div>
<ChevronDown
className={`h-4 w-4 text-muted-foreground transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="fade-in-0 slide-in-from-top-2 animate-in space-y-2 duration-200">
{displayedRuns.map((run) => (
<Button
key={run.id}
variant="ghost"
onClick={() => setViewingRun(run)}
className="h-auto w-full justify-start rounded-xl border border-border/50 bg-card p-4 text-left transition-all hover:border-border"
>
<div className="flex w-full items-start gap-3">
{getStatusIcon(run.status)}
<div className="min-w-0 flex-1">
<div className="mb-1 flex items-center gap-2">
<span className="truncate font-medium text-foreground text-sm">
{run.job?.name}
</span>
<span className="flex items-center gap-1 text-muted-foreground text-xs">
<Clock className="h-3 w-3" />
{formatTimestamp(run.startedAt)}
</span>
</div>
{run.result && (
<p className="line-clamp-2 text-ellipsis text-muted-foreground text-xs">
{run.result}
</p>
)}
</div>
</div>
</Button>
))}
</CollapsibleContent>
<RunResultDialog
run={viewingRun}
jobName={viewingRun?.job?.name}
onOpenChange={(open) => !open && setViewingRun(null)}
/>
</Collapsible>
)
}

View File

@@ -5,6 +5,7 @@ import { ConnectMCP } from './connect-mcp/ConnectMCP'
import { DashboardLayout } from './layout/DashboardLayout'
import { LlmHubPage } from './llm-hub/LlmHubPage'
import { MCPSettingsPage } from './mcp-settings/MCPSettingsPage'
import { ScheduledTasksPage } from './scheduled-tasks/ScheduledTasksPage'
export const App: FC = () => {
return (
@@ -21,6 +22,7 @@ export const App: FC = () => {
path="onboarding"
element={<AISettingsPage key="onboarding" />}
/>
<Route path="scheduled" element={<ScheduledTasksPage />} />
</Route>
</Routes>
</HashRouter>

View File

@@ -1,5 +1,6 @@
import {
Bot,
CalendarClock,
Info,
type LucideIcon,
Menu,
@@ -52,6 +53,12 @@ const navigationItems: NavItem[] = [
enabled: true,
feature: Feature.MANAGED_MCP_SUPPORT,
},
{
name: 'Scheduled Tasks',
to: '/scheduled',
icon: CalendarClock,
enabled: true,
},
{
name: 'BrowserOS MCP',
to: '/mcp',

View File

@@ -0,0 +1,298 @@
import { zodResolver } from '@hookform/resolvers/zod'
import type { FC } from 'react'
import { useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import type { ScheduledJob } from './types'
const formSchema = z
.object({
name: z
.string()
.min(1, 'Name is required')
.max(100, 'Name must be 100 characters or less'),
query: z
.string()
.min(1, 'Query is required')
.max(2000, 'Query must be 2000 characters or less'),
scheduleType: z.enum(['daily', 'hourly', 'minutes']),
scheduleTime: z.string().optional(),
scheduleInterval: z.number().int().min(1).max(60).optional(),
enabled: z.boolean(),
})
.superRefine((data, ctx) => {
if (data.scheduleType === 'daily' && !data.scheduleTime) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Time is required for daily schedule',
path: ['scheduleTime'],
})
}
if (
(data.scheduleType === 'hourly' || data.scheduleType === 'minutes') &&
(!data.scheduleInterval || data.scheduleInterval < 1)
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Interval must be at least 1',
path: ['scheduleInterval'],
})
}
})
type FormValues = z.infer<typeof formSchema>
interface NewScheduledTaskDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
initialValues?: ScheduledJob | null
onSave: (data: Omit<ScheduledJob, 'id' | 'createdAt'>) => void
}
export const NewScheduledTaskDialog: FC<NewScheduledTaskDialogProps> = ({
open,
onOpenChange,
initialValues,
onSave,
}) => {
const isEditing = !!initialValues
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
name: '',
query: '',
scheduleType: 'daily',
scheduleTime: '09:00',
scheduleInterval: 1,
enabled: true,
},
})
const scheduleType = form.watch('scheduleType')
useEffect(() => {
if (open) {
if (initialValues) {
form.reset({
name: initialValues.name,
query: initialValues.query,
scheduleType: initialValues.scheduleType,
scheduleTime: initialValues.scheduleTime || '09:00',
scheduleInterval: initialValues.scheduleInterval || 1,
enabled: initialValues.enabled,
})
} else {
form.reset({
name: '',
query: '',
scheduleType: 'daily',
scheduleTime: '09:00',
scheduleInterval: 1,
enabled: true,
})
}
}
}, [open, initialValues, form])
const onSubmit = (values: FormValues) => {
onSave({
name: values.name.trim(),
query: values.query.trim(),
scheduleType: values.scheduleType,
scheduleTime:
values.scheduleType === 'daily' ? values.scheduleTime : undefined,
scheduleInterval:
values.scheduleType !== 'daily' ? values.scheduleInterval : undefined,
enabled: values.enabled,
})
form.reset()
onOpenChange(false)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>
{isEditing ? 'Edit Scheduled Task' : 'Create Scheduled Task'}
</DialogTitle>
<DialogDescription>
{isEditing
? 'Update your scheduled task configuration.'
: 'Create a new task that runs automatically on a schedule.'}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="e.g., Morning Briefing" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="query"
render={({ field }) => (
<FormItem>
<FormLabel>Query</FormLabel>
<FormControl>
<Textarea
placeholder="What should the agent do? e.g., Check my email and summarize important messages"
className="min-h-[100px] resize-none"
{...field}
/>
</FormControl>
<FormDescription>
The instruction that will be sent to the agent
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="grid gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="scheduleType"
render={({ field }) => (
<FormItem>
<FormLabel>Schedule</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select schedule type" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="daily">Daily at time</SelectItem>
<SelectItem value="hourly">Every N hours</SelectItem>
<SelectItem value="minutes">Every N minutes</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{scheduleType === 'daily' ? (
<FormField
control={form.control}
name="scheduleTime"
render={({ field }) => (
<FormItem>
<FormLabel>Time</FormLabel>
<FormControl>
<Input type="time" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
) : (
<FormField
control={form.control}
name="scheduleInterval"
render={({ field }) => (
<FormItem>
<FormLabel>
Interval (
{scheduleType === 'hourly' ? 'hours' : 'minutes'})
</FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={scheduleType === 'hourly' ? 24 : 60}
value={field.value ?? ''}
onChange={(e) =>
field.onChange(
e.target.value
? Number(e.target.value)
: undefined,
)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex flex-row items-center gap-3 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="font-normal">
Enable this task
</FormLabel>
</FormItem>
)}
/>
<DialogFooter className="gap-2 pt-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button type="submit">{isEditing ? 'Update' : 'Create'}</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,185 @@
import dayjs from 'dayjs'
import duration from 'dayjs/plugin/duration'
import relativeTime from 'dayjs/plugin/relativeTime'
import {
CheckCircle2,
ChevronDown,
Loader2,
Pencil,
Trash2,
XCircle,
} from 'lucide-react'
import { type FC, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { Switch } from '@/components/ui/switch'
import { useScheduledJobRuns } from '@/lib/schedules/scheduleStorage'
import type { ScheduledJob, ScheduledJobRun } from './types'
dayjs.extend(relativeTime)
dayjs.extend(duration)
interface ScheduledTaskCardProps {
job: ScheduledJob
onEdit: () => void
onDelete: () => void
onToggle: (enabled: boolean) => void
onViewRun: (run: ScheduledJobRun) => void
}
function formatSchedule(job: ScheduledJob): string {
if (job.scheduleType === 'daily' && job.scheduleTime) {
return `Daily at ${job.scheduleTime}`
}
if (job.scheduleType === 'hourly' && job.scheduleInterval) {
return job.scheduleInterval === 1
? 'Every hour'
: `Every ${job.scheduleInterval} hours`
}
if (job.scheduleType === 'minutes' && job.scheduleInterval) {
return job.scheduleInterval === 1
? 'Every minute'
: `Every ${job.scheduleInterval} minutes`
}
return 'Not scheduled'
}
const formatRelativeTime = (dateStr: string) => dayjs(dateStr).fromNow()
function formatDuration(startedAt: string, completedAt?: string): string {
if (!completedAt) return 'Running...'
const diff = dayjs(completedAt).diff(dayjs(startedAt))
const d = dayjs.duration(diff)
const mins = Math.floor(d.asMinutes())
const secs = d.seconds()
if (mins === 0) return `${secs}s`
return `${mins}m ${secs}s`
}
const formatRunDate = (dateStr: string) =>
dayjs(dateStr).format('MMM D, h:mm A')
export const ScheduledTaskCard: FC<ScheduledTaskCardProps> = ({
job,
onEdit,
onDelete,
onToggle,
onViewRun,
}) => {
const [isOpen, setIsOpen] = useState(false)
const { jobRuns } = useScheduledJobRuns()
const runs = useMemo(
() =>
jobRuns
.filter((run) => run.jobId === job.id)
.sort(
(a, b) =>
new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime(),
),
[jobRuns, job.id],
)
return (
<div className="rounded-xl border border-border bg-card p-4 shadow-sm transition-all hover:border-[var(--accent-orange)]/50 hover:shadow-sm">
<div className="flex items-start gap-4">
<Switch
checked={job.enabled}
onCheckedChange={onToggle}
aria-label={`${job.enabled ? 'Disable' : 'Enable'} ${job.name}`}
/>
<div className="min-w-0 flex-1">
<div className="mb-1 flex items-center gap-2">
<span className="truncate font-semibold">{job.name}</span>
{!job.enabled && (
<span className="rounded bg-muted px-1.5 py-0.5 text-muted-foreground text-xs">
Disabled
</span>
)}
</div>
<p className="mb-2 line-clamp-1 text-muted-foreground text-sm">
"{job.query}"
</p>
<div className="flex items-center gap-2 text-muted-foreground text-xs">
<span>{formatSchedule(job)}</span>
{job.lastRunAt && (
<>
<span></span>
<span>Last run: {formatRelativeTime(job.lastRunAt)}</span>
</>
)}
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button variant="outline" size="sm" onClick={onEdit}>
<Pencil className="mr-1.5 h-3 w-3" />
Edit
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={onDelete}
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
aria-label={`Delete ${job.name}`}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
{runs.length > 0 && (
<Collapsible open={isOpen} onOpenChange={setIsOpen} className="mt-4">
<CollapsibleTrigger className="flex w-full items-center gap-2 text-muted-foreground text-sm hover:text-foreground">
<ChevronDown
className={`h-4 w-4 transition-transform duration-200 ${
isOpen ? 'rotate-180' : ''
}`}
/>
<span>Run History ({runs.length})</span>
</CollapsibleTrigger>
<CollapsibleContent className="pt-3">
<div className="space-y-2">
{runs.map((run) => (
<div
key={run.id}
className="flex items-center gap-3 rounded-lg border border-border bg-background p-3"
>
{run.status === 'completed' ? (
<CheckCircle2 className="h-4 w-4 shrink-0 text-green-500" />
) : run.status === 'failed' ? (
<XCircle className="h-4 w-4 shrink-0 text-destructive" />
) : (
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-accent-orange" />
)}
<div className="min-w-0 flex-1">
<span className="text-sm">
{formatRunDate(run.startedAt)}
</span>
<span className="ml-2 text-muted-foreground text-xs">
{formatDuration(run.startedAt, run.completedAt)}
</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => onViewRun(run)}
className="text-muted-foreground hover:text-foreground"
>
View
</Button>
</div>
))}
</div>
</CollapsibleContent>
</Collapsible>
)}
</div>
)
}

View File

@@ -0,0 +1,35 @@
import { CalendarClock, Plus } from 'lucide-react'
import type { FC } from 'react'
import { Button } from '@/components/ui/button'
interface ScheduledTasksHeaderProps {
onAddClick: () => void
}
export const ScheduledTasksHeader: FC<ScheduledTasksHeaderProps> = ({
onAddClick,
}) => {
return (
<div className="rounded-xl border border-border bg-card p-6 shadow-sm transition-all hover:shadow-md">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-[var(--accent-orange)]/10">
<CalendarClock className="h-6 w-6 text-[var(--accent-orange)]" />
</div>
<div className="flex-1">
<h2 className="mb-1 font-semibold text-xl">Scheduled Tasks</h2>
<p className="text-muted-foreground text-sm">
Automate recurring browser tasks
</p>
</div>
<Button
onClick={onAddClick}
className="border-[var(--accent-orange)] bg-[var(--accent-orange)]/10 text-[var(--accent-orange)] hover:bg-[var(--accent-orange)]/20 hover:text-[var(--accent-orange)]"
variant="outline"
>
<Plus className="mr-1.5 h-4 w-4" />
New Task
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,46 @@
import type { FC } from 'react'
import { ScheduledTaskCard } from './ScheduledTaskCard'
import type { ScheduledJob, ScheduledJobRun } from './types'
interface ScheduledTasksListProps {
jobs: ScheduledJob[]
onEdit: (job: ScheduledJob) => void
onDelete: (jobId: string) => void
onToggle: (jobId: string, enabled: boolean) => void
onViewRun: (run: ScheduledJobRun) => void
}
export const ScheduledTasksList: FC<ScheduledTasksListProps> = ({
jobs,
onEdit,
onDelete,
onToggle,
onViewRun,
}) => {
if (jobs.length === 0) {
return (
<div className="rounded-xl border border-border bg-card p-6 shadow-sm">
<div className="rounded-lg border border-border border-dashed py-8 text-center">
<p className="text-muted-foreground text-sm">
No scheduled tasks yet. Create one to automate recurring workflows.
</p>
</div>
</div>
)
}
return (
<div className="space-y-3">
{jobs.map((job) => (
<ScheduledTaskCard
key={job.id}
job={job}
onEdit={() => onEdit(job)}
onDelete={() => onDelete(job.id)}
onToggle={(enabled) => onToggle(job.id, enabled)}
onViewRun={onViewRun}
/>
))}
</div>
)
}

View File

@@ -0,0 +1,124 @@
import { type FC, useState } from 'react'
import { RunResultDialog } from '@/components/ai-elements/run-result-dialog'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { useScheduledJobs } from '@/lib/schedules/scheduleStorage'
import type { ScheduledJobRun } from '@/lib/schedules/scheduleTypes'
import { NewScheduledTaskDialog } from './NewScheduledTaskDialog'
import { ScheduledTasksHeader } from './ScheduledTasksHeader'
import { ScheduledTasksList } from './ScheduledTasksList'
import type { ScheduledJob } from './types'
/**
* Main page for managing scheduled tasks
* @public
*/
export const ScheduledTasksPage: FC = () => {
const { jobs, addJob, editJob, toggleJob, removeJob } = useScheduledJobs()
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [editingJob, setEditingJob] = useState<ScheduledJob | null>(null)
const [deleteJobId, setDeleteJobId] = useState<string | null>(null)
const [viewingRun, setViewingRun] = useState<ScheduledJobRun | null>(null)
const handleAdd = () => {
setEditingJob(null)
setIsDialogOpen(true)
}
const handleEdit = (job: ScheduledJob) => {
setEditingJob(job)
setIsDialogOpen(true)
}
const handleDelete = (jobId: string) => {
setDeleteJobId(jobId)
}
const confirmDelete = async () => {
if (deleteJobId) {
await removeJob(deleteJobId)
setDeleteJobId(null)
}
}
const handleSave = async (data: Omit<ScheduledJob, 'id' | 'createdAt'>) => {
if (editingJob) {
await editJob(editingJob.id, data)
} else {
await addJob(data)
}
}
const handleToggle = async (jobId: string, enabled: boolean) => {
await toggleJob(jobId, enabled)
}
const handleViewRun = (run: ScheduledJobRun) => {
setViewingRun(run)
}
const jobToDelete = deleteJobId
? jobs.find((j) => j.id === deleteJobId)
: null
return (
<div className="fade-in slide-in-from-bottom-5 animate-in space-y-6 duration-500">
<ScheduledTasksHeader onAddClick={handleAdd} />
<ScheduledTasksList
jobs={jobs}
onEdit={handleEdit}
onDelete={handleDelete}
onToggle={handleToggle}
onViewRun={handleViewRun}
/>
<NewScheduledTaskDialog
open={isDialogOpen}
onOpenChange={setIsDialogOpen}
initialValues={editingJob}
onSave={handleSave}
/>
<RunResultDialog
run={viewingRun}
jobName={
viewingRun
? jobs.find((j) => j.id === viewingRun.jobId)?.name
: undefined
}
onOpenChange={(open) => !open && setViewingRun(null)}
/>
<AlertDialog
open={deleteJobId !== null}
onOpenChange={(open) => !open && setDeleteJobId(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Scheduled Task</AlertDialogTitle>
<AlertDialogDescription>
Delete "{jobToDelete?.name}"? This will also remove all run
history for this task.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={confirmDelete}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@@ -0,0 +1,13 @@
import type {
ScheduledJob,
ScheduledJobRun,
} from '@/lib/schedules/scheduleTypes'
export type { ScheduledJob, ScheduledJobRun }
export interface ScheduledTasksStorage {
loadJobs(): Promise<ScheduledJob[]>
saveJobs(jobs: ScheduledJob[]): Promise<void>
loadRuns(): Promise<ScheduledJobRun[]>
saveRuns(runs: ScheduledJobRun[]): Promise<void>
}

View File

@@ -177,6 +177,7 @@ export const useChatSession = () => {
error: chatError,
} = useChat({
transport: new DefaultChatTransport({
// Important: this chat logic is also used in apps/agent/lib/schedules/getChatServerResponse.ts for scheduled jobs. Make sure to keep them in sync for any future changes.
prepareSendMessagesRequest: async ({ messages }) => {
const activeTabsList = await chrome.tabs.query({
active: true,

View File

@@ -0,0 +1,43 @@
import type { ScheduledJob } from './scheduleTypes'
const getNextScheduledTime = (timeString: string): number => {
const [hours, minutes] = timeString.split(':').map(Number)
const now = new Date()
const scheduled = new Date()
scheduled.setHours(hours, minutes, 0, 0)
// If time has passed today, schedule for tomorrow
if (scheduled.getTime() <= now.getTime()) {
scheduled.setDate(scheduled.getDate() + 1)
}
return scheduled.getTime()
}
export const createAlarmFromJob = async (job: ScheduledJob) => {
const alarmName = `scheduled-job-${job.id}`
let time: chrome.alarms.AlarmCreateInfo | undefined
if (job.scheduleType === 'daily') {
time = {
when: getNextScheduledTime(job.scheduleTime!),
periodInMinutes: 24 * 60, // Repeat every 24 hours
}
} else if (job.scheduleType === 'hourly') {
const intervalInMinutes = job.scheduleInterval! * 60
time = {
delayInMinutes: intervalInMinutes,
periodInMinutes: intervalInMinutes,
}
} else if (job.scheduleType === 'minutes') {
time = {
delayInMinutes: job.scheduleInterval,
periodInMinutes: job.scheduleInterval,
}
}
if (time) {
await chrome.alarms.create(alarmName, time)
}
}

View File

@@ -0,0 +1,161 @@
import type { ChatMode } from '@/entrypoints/sidepanel/index/chatTypes'
import { getAgentServerUrl } from '@/lib/browseros/helpers'
import {
defaultProviderIdStorage,
providersStorage,
} from '@/lib/llm-providers/storage'
import type { LlmProviderConfig } from '@/lib/llm-providers/types'
import { mcpServerStorage } from '@/lib/mcp/mcpServerStorage'
interface ActiveTab {
id?: number
url?: string
title?: string
}
interface ChatServerRequest {
message: string
mode?: ChatMode
conversationId?: string
windowId?: number
activeTab?: ActiveTab
}
interface ChatServerResponse {
text: string
conversationId: string
}
interface StreamEvent {
type: string
delta?: string
}
const getDefaultProvider = async (): Promise<LlmProviderConfig | null> => {
const providers = await providersStorage.getValue()
if (!providers?.length) return null
const defaultProviderId = await defaultProviderIdStorage.getValue()
const defaultProvider = providers.find((p) => p.id === defaultProviderId)
return defaultProvider ?? providers[0] ?? null
}
export async function getChatServerResponse(
request: ChatServerRequest,
): Promise<ChatServerResponse> {
const agentServerUrl = await getAgentServerUrl()
const provider = await getDefaultProvider()
const conversationId = request.conversationId ?? crypto.randomUUID()
const mcpServers = (await mcpServerStorage.getValue()) ?? []
const enabledMcpServers = mcpServers
.filter((s) => s.type === 'managed')
.map((s) => s.managedServerName)
.filter((name): name is string => !!name)
const customMcpServers = mcpServers
.filter((s) => s.type === 'custom' && s.config?.url)
.map((s) => ({ name: s.displayName, url: s.config!.url! }))
const response = await fetch(`${agentServerUrl}/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
// Important: this chat logic is also used in apps/agent/entrypoints/sidepanel/index/useChatSession.ts for sidepanel conversation. Make sure to keep them in sync for any future changes.
body: JSON.stringify({
messages: [{ role: 'user', content: request.message }],
message: request.message,
provider: provider?.type,
providerType: provider?.type,
providerName: provider?.name,
apiKey: provider?.apiKey,
baseUrl: provider?.baseUrl,
conversationId,
model: provider?.modelId ?? 'default',
mode: request.mode ?? 'agent',
resourceName: provider?.resourceName,
accessKeyId: provider?.accessKeyId,
secretAccessKey: provider?.secretAccessKey,
region: provider?.region,
sessionToken: provider?.sessionToken,
browserContext:
request.activeTab ||
request.windowId ||
enabledMcpServers.length ||
customMcpServers.length
? {
windowId: request.windowId,
activeTab: request.activeTab,
enabledMcpServers:
enabledMcpServers.length > 0 ? enabledMcpServers : undefined,
customMcpServers:
customMcpServers.length > 0 ? customMcpServers : undefined,
}
: undefined,
}),
})
if (!response.ok) {
throw new Error(
`Chat request failed: ${response.status} ${response.statusText}`,
)
}
const text = await parseSSEStream(response)
return { text, conversationId }
}
async function parseSSEStream(response: Response): Promise<string> {
const reader = response.body?.getReader()
if (!reader) {
throw new Error('Response body is not readable')
}
const decoder = new TextDecoder()
let result = ''
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
if (!line.startsWith('data: ')) continue
const data = line.slice(6)
if (data === '[DONE]') continue
try {
const event: StreamEvent = JSON.parse(data)
if (event.type === 'text-delta' && event.delta) {
result += event.delta
}
} catch {
// Ignore parse errors
}
}
}
// Process remaining buffer
if (buffer.startsWith('data: ')) {
const data = buffer.slice(6)
if (data !== '[DONE]') {
try {
const event: StreamEvent = JSON.parse(data)
if (event.type === 'text-delta' && event.delta) {
result += event.delta
}
} catch {
// Ignore parse errors
}
}
}
return result
}

View File

@@ -0,0 +1,133 @@
import { storage } from '@wxt-dev/storage'
import { useEffect, useState } from 'react'
import { createAlarmFromJob } from './createAlarmFromJob'
import type { ScheduledJob, ScheduledJobRun } from './scheduleTypes'
const getAlarmName = (jobId: string) => `scheduled-job-${jobId}`
export const scheduledJobStorage = storage.defineItem<ScheduledJob[]>(
'local:scheduledJobs',
{
fallback: [],
},
)
export const scheduledJobRunStorage = storage.defineItem<ScheduledJobRun[]>(
'local:scheduledJobRuns',
{
fallback: [],
},
)
export function useScheduledJobs() {
const [jobs, setJobs] = useState<ScheduledJob[]>([])
useEffect(() => {
scheduledJobStorage.getValue().then(setJobs)
const unwatch = scheduledJobStorage.watch((newValue) => {
setJobs(newValue ?? [])
})
return unwatch
}, [])
const addJob = async (job: Omit<ScheduledJob, 'id' | 'createdAt'>) => {
const newJob: ScheduledJob = {
id: crypto.randomUUID(),
createdAt: new Date().toISOString(),
...job,
}
const current = (await scheduledJobStorage.getValue()) ?? []
await scheduledJobStorage.setValue([...current, newJob])
if (newJob.enabled) {
await createAlarmFromJob(newJob)
}
}
const removeJob = async (id: string) => {
await chrome.alarms.clear(getAlarmName(id))
const currentJobs = (await scheduledJobStorage.getValue()) ?? []
await scheduledJobStorage.setValue(currentJobs.filter((j) => j.id !== id))
const currentRuns = (await scheduledJobRunStorage.getValue()) ?? []
await scheduledJobRunStorage.setValue(
currentRuns.filter((r) => r.jobId !== id),
)
}
const toggleJob = async (id: string, enabled: boolean) => {
const current = (await scheduledJobStorage.getValue()) ?? []
const job = current.find((j) => j.id === id)
if (!job) return
await scheduledJobStorage.setValue(
current.map((j) => (j.id === id ? { ...j, enabled } : j)),
)
if (enabled) {
await createAlarmFromJob({ ...job, enabled })
} else {
await chrome.alarms.clear(getAlarmName(id))
}
}
const editJob = async (
id: string,
updates: Omit<ScheduledJob, 'id' | 'createdAt'>,
) => {
const current = (await scheduledJobStorage.getValue()) ?? []
const existingJob = current.find((j) => j.id === id)
if (!existingJob) return
const updatedJob: ScheduledJob = {
id,
createdAt: existingJob.createdAt,
...updates,
}
await scheduledJobStorage.setValue(
current.map((j) => (j.id === id ? updatedJob : j)),
)
await chrome.alarms.clear(getAlarmName(id))
if (updatedJob.enabled) {
await createAlarmFromJob(updatedJob)
}
}
return { jobs, addJob, removeJob, editJob, toggleJob }
}
export function useScheduledJobRuns() {
const [jobRuns, setJobRuns] = useState<ScheduledJobRun[]>([])
useEffect(() => {
scheduledJobRunStorage.getValue().then(setJobRuns)
const unwatch = scheduledJobRunStorage.watch((newValue) => {
setJobRuns(newValue ?? [])
})
return unwatch
}, [])
const addJobRun = async (jobRun: ScheduledJobRun) => {
const current = (await scheduledJobRunStorage.getValue()) ?? []
await scheduledJobRunStorage.setValue([...current, jobRun])
}
const removeJobRun = async (id: string) => {
const current = (await scheduledJobRunStorage.getValue()) ?? []
await scheduledJobRunStorage.setValue(current.filter((r) => r.id !== id))
}
const editJobRun = async (
id: string,
updates: Partial<Omit<ScheduledJobRun, 'id'>>,
) => {
const current = (await scheduledJobRunStorage.getValue()) ?? []
await scheduledJobRunStorage.setValue(
current.map((r) => (r.id === id ? { ...r, ...updates } : r)),
)
}
return { jobRuns, addJobRun, removeJobRun, editJobRun }
}

View File

@@ -0,0 +1,20 @@
export interface ScheduledJob {
id: string
name: string
query: string
scheduleType: 'daily' | 'hourly' | 'minutes'
scheduleTime?: string
scheduleInterval?: number
enabled: boolean
createdAt: string
lastRunAt?: string
}
export interface ScheduledJobRun {
id: string
jobId: string
startedAt: string
completedAt?: string
status: 'running' | 'completed' | 'failed'
result?: string
}

View File

@@ -49,11 +49,14 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dayjs": "^1.11.19",
"dompurify": "^3.3.1",
"downshift": "^9.0.10",
"embla-carousel-react": "^8.6.0",
"es-toolkit": "^1.42.0",
"klavis": "^2.15.0",
"lucide-react": "^0.554.0",
"marked": "^17.0.1",
"motion": "^12.23.24",
"nanoid": "^5.1.6",
"next-themes": "^0.4.6",
@@ -75,7 +78,7 @@
"zod": "^4.1.13"
},
"devDependencies": {
"@biomejs/biome": "2.3.9",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.17",
"@types/chrome": "^0.1.28",
"@types/react": "^19.1.12",

View File

@@ -1,6 +1,7 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "tailwind-scrollbar-hide/v4";
@plugin "@tailwindcss/typography";
@custom-variant dark (&:is(.dark *));

View File

@@ -23,7 +23,6 @@ export default defineConfig({
`https://${PRODUCT_WEB_HOST}/*`,
`https://*.${PRODUCT_WEB_HOST}/*`,
],
// @ts-expect-error - extension_ids type is missing in wxt types
extension_ids: [LEGACY_AGENT_EXTENSION_ID],
},
],
@@ -39,7 +38,14 @@ export default defineConfig({
},
default_title: 'Ask BrowserOS',
},
permissions: ['topSites', 'tabs', 'storage', 'sidePanel', 'browserOS'],
permissions: [
'topSites',
'tabs',
'storage',
'sidePanel',
'browserOS',
'alarms',
],
host_permissions: [
'http://127.0.0.1/*',
'https://suggestqueries.google.com/*',

View File

@@ -49,11 +49,14 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dayjs": "^1.11.19",
"dompurify": "^3.3.1",
"downshift": "^9.0.10",
"embla-carousel-react": "^8.6.0",
"es-toolkit": "^1.42.0",
"klavis": "^2.15.0",
"lucide-react": "^0.554.0",
"marked": "^17.0.1",
"motion": "^12.23.24",
"nanoid": "^5.1.6",
"next-themes": "^0.4.6",
@@ -75,7 +78,7 @@
"zod": "^4.1.13",
},
"devDependencies": {
"@biomejs/biome": "2.3.9",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.17",
"@types/chrome": "^0.1.28",
"@types/react": "^19.1.12",
@@ -942,6 +945,8 @@
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.18", "", { "os": "win32", "cpu": "x64" }, "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q=="],
"@tailwindcss/typography": ["@tailwindcss/typography@0.5.19", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg=="],
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="],
"@tokenlens/core": ["@tokenlens/core@1.3.0", "", {}, "sha512-d8YNHNC+q10bVpi95fELJwJyPVf1HfvBEI18eFQxRSZTdByXrP+f/ZtlhSzkx0Jl0aEmYVeBA5tPeeYRioLViQ=="],
@@ -1448,6 +1453,8 @@
"css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="],
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"cssom": ["cssom@0.5.0", "", {}, "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
@@ -2208,7 +2215,7 @@
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
"marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
"marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
"marky": ["marky@1.3.0", "", {}, "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ=="],
@@ -2546,6 +2553,8 @@
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="],
"postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
"postgres-bytea": ["postgres-bytea@1.0.0", "", {}, "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w=="],
@@ -3326,6 +3335,8 @@
"@google/gemini-cli-core/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"@google/gemini-cli-core/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
"@google/genai/google-auth-library": ["google-auth-library@10.5.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.0.0", "gcp-metadata": "^8.0.0", "google-logging-utils": "^1.0.0", "gtoken": "^8.0.0", "jws": "^4.0.0" } }, "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w=="],
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
@@ -3342,8 +3353,6 @@
"@lobehub/ui/lucide-react": ["lucide-react@0.562.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw=="],
"@lobehub/ui/marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
"@lobehub/ui/url-join": ["url-join@5.0.0", "", {}, "sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA=="],
"@lobehub/ui/uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="],
@@ -3554,8 +3563,6 @@
"accepts/mime-types": ["mime-types@3.0.1", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA=="],
"agent/@biomejs/biome": ["@biomejs/biome@2.3.9", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.9", "@biomejs/cli-darwin-x64": "2.3.9", "@biomejs/cli-linux-arm64": "2.3.9", "@biomejs/cli-linux-arm64-musl": "2.3.9", "@biomejs/cli-linux-x64": "2.3.9", "@biomejs/cli-linux-x64-musl": "2.3.9", "@biomejs/cli-win32-arm64": "2.3.9", "@biomejs/cli-win32-x64": "2.3.9" }, "bin": { "biome": "bin/biome" } }, "sha512-js+34KpnY65I00k8P71RH0Uh2rJk4BrpxMGM5m2nBfM9XTlKE5N1URn5ydILPRyXXq4ebhKCjsvR+txS+D4z2A=="],
"agent/zod": ["zod@4.2.1", "", {}, "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw=="],
"ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="],
@@ -3938,22 +3945,6 @@
"accepts/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
"agent/@biomejs/biome/@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-hHbYYnna/WBwem5iCpssQQLtm5ey8ADuDT8N2zqosk6LVWimlEuUnPy6Mbzgu4GWVriyL5ijWd+1zphX6ll4/A=="],
"agent/@biomejs/biome/@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-sKMW5fpvGDmPdqCchtVH5MVlbVeSU3ad4CuKS45x8VHt3tNSC8CZ2QbxffAOKYK9v/mAeUiPC6Cx6+wtyU1q7g=="],
"agent/@biomejs/biome/@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-BXBB6HbAgZI6T6QB8q6NSwIapVngqArP6K78BqkMerht7YjL6yWctqfeTnJm0qGF2bKBYFexslrbV+VTlM2E6g=="],
"agent/@biomejs/biome/@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-JOHyG2nl8XDpncbMazm1uBSi1dPX9VbQDOjKcfSVXTqajD0PsgodMOKyuZ/PkBu5Lw877sWMTGKfEfpM7jE7Cw=="],
"agent/@biomejs/biome/@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.9", "", { "os": "linux", "cpu": "x64" }, "sha512-PjYuv2WLmvf0WtidxAkFjlElsn0P6qcvfPijrqu1j+3GoW3XSQh3ywGu7gZ25J25zrYj3KEovUjvUZB55ATrGw=="],
"agent/@biomejs/biome/@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.9", "", { "os": "linux", "cpu": "x64" }, "sha512-FUkb/5beCIC2trpqAbW9e095X4vamdlju80c1ExSmhfdrojLZnWkah/XfTSixKb/dQzbAjpD7vvs6rWkJ+P07Q=="],
"agent/@biomejs/biome/@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-w48Yh/XbYHO2cBw8B5laK3vCAEKuocX5ItGXVDAqFE7Ze2wnR00/1vkY6GXglfRDOjWHu2XtxI0WKQ52x1qxEA=="],
"agent/@biomejs/biome/@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.9", "", { "os": "win32", "cpu": "x64" }, "sha512-90+J63VT7qImy9s3pkWL0ZX27VzVwMNCRzpLpe5yMzMYPbO1vcjL/w/Q5f/juAGMvP7a2Fd0H7IhAR6F7/i78A=="],
"ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"ansi-align/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],