diff --git a/apps/agent/biome.json b/apps/agent/biome.json index d6cc9932f..633fe8b1e 100644 --- a/apps/agent/biome.json +++ b/apps/agent/biome.json @@ -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": { diff --git a/apps/agent/components/ai-elements/run-result-dialog.tsx b/apps/agent/components/ai-elements/run-result-dialog.tsx new file mode 100644 index 000000000..cb02c216d --- /dev/null +++ b/apps/agent/components/ai-elements/run-result-dialog.tsx @@ -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 = ({ + 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 ( + + + + + {run.status === 'completed' ? ( + + ) : run.status === 'failed' ? ( + + ) : ( + + )} + {jobName || 'Run Result'} + +
+ {formatDateTime(run.startedAt)} •{' '} + {formatDuration(run.startedAt, run.completedAt)} +
+
+ + + {renderedContent ? ( +
+ ) : ( +
+ No result available +
+ )} + + + + {run.result && ( + + )} + + + +
+ ) +} diff --git a/apps/agent/entrypoints/background.ts b/apps/agent/entrypoints/background/index.ts similarity index 92% rename from apps/agent/entrypoints/background.ts rename to apps/agent/entrypoints/background/index.ts index 39a7030f1..f6fb99a84 100644 --- a/apps/agent/entrypoints/background.ts +++ b/apps/agent/entrypoints/background/index.ts @@ -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) diff --git a/apps/agent/entrypoints/background/scheduledJobRuns.ts b/apps/agent/entrypoints/background/scheduledJobRuns.ts new file mode 100644 index 000000000..5f4b979de --- /dev/null +++ b/apps/agent/entrypoints/background/scheduledJobRuns.ts @@ -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 => { + 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>, + ) => { + 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() + }) +} diff --git a/apps/agent/entrypoints/newtab/index/FooterLinks.tsx b/apps/agent/entrypoints/newtab/index/FooterLinks.tsx new file mode 100644 index 000000000..fb5507077 --- /dev/null +++ b/apps/agent/entrypoints/newtab/index/FooterLinks.tsx @@ -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 = ({ onOpenShortcuts }) => { + const { supports } = useCapabilities() + + return ( +
+ + + Scheduler{' '} + + (new) + + + + + {supports(Feature.MANAGED_MCP_SUPPORT) && ( + <> + + Connect MCP servers{' '} + + + + )} + + Settings + + + + +
+ ) +} diff --git a/apps/agent/entrypoints/newtab/index/NewTab.tsx b/apps/agent/entrypoints/newtab/index/NewTab.tsx index 1995a7f87..0d3d43184 100644 --- a/apps/agent/entrypoints/newtab/index/NewTab.tsx +++ b/apps/agent/entrypoints/newtab/index/NewTab.tsx @@ -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(null) const [selectedTabs, setSelectedTabs] = useState([]) 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 && } - {/* Footer links */} {!isSuggestionsVisible && ( -
- - Settings - - - - - - {supports(Feature.MANAGED_MCP_SUPPORT) && ( - <> - - - Connect MCP servers{' '} - (new) - - - )} - {/* - - Shortcuts - - - - Personalize - */} -
+ setShortcutsDialogOpen(true)} /> )} + + {mounted && !isSuggestionsVisible && } {mounted && ( { + switch (status) { + case 'completed': + return + case 'running': + return + case 'failed': + return + } +} + +const formatTimestamp = (dateString: string) => dayjs(dateString).fromNow() + +export const ScheduleResults: FC = () => { + const [isOpen, setIsOpen] = useState(true) + const [viewingRun, setViewingRun] = useState(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 ( + + + + + + + {displayedRuns.map((run) => ( + + ))} + + + !open && setViewingRun(null)} + /> + + ) +} diff --git a/apps/agent/entrypoints/options/App.tsx b/apps/agent/entrypoints/options/App.tsx index 4ef984cbd..d780aca83 100644 --- a/apps/agent/entrypoints/options/App.tsx +++ b/apps/agent/entrypoints/options/App.tsx @@ -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={} /> + } /> diff --git a/apps/agent/entrypoints/options/layout/DashboardLayout.tsx b/apps/agent/entrypoints/options/layout/DashboardLayout.tsx index a609ff849..b78f519c6 100644 --- a/apps/agent/entrypoints/options/layout/DashboardLayout.tsx +++ b/apps/agent/entrypoints/options/layout/DashboardLayout.tsx @@ -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', diff --git a/apps/agent/entrypoints/options/scheduled-tasks/NewScheduledTaskDialog.tsx b/apps/agent/entrypoints/options/scheduled-tasks/NewScheduledTaskDialog.tsx new file mode 100644 index 000000000..f46519ec1 --- /dev/null +++ b/apps/agent/entrypoints/options/scheduled-tasks/NewScheduledTaskDialog.tsx @@ -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 + +interface NewScheduledTaskDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + initialValues?: ScheduledJob | null + onSave: (data: Omit) => void +} + +export const NewScheduledTaskDialog: FC = ({ + open, + onOpenChange, + initialValues, + onSave, +}) => { + const isEditing = !!initialValues + + const form = useForm({ + 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 ( + + + + + {isEditing ? 'Edit Scheduled Task' : 'Create Scheduled Task'} + + + {isEditing + ? 'Update your scheduled task configuration.' + : 'Create a new task that runs automatically on a schedule.'} + + +
+ + ( + + Name + + + + + + )} + /> + + ( + + Query + +