diff --git a/apps/web/src/components/agent-triggers/AgentTriggerSection.tsx b/apps/web/src/components/agent-triggers/AgentTriggerSection.tsx index 316f9f59d2..7dc808dbfb 100644 --- a/apps/web/src/components/agent-triggers/AgentTriggerSection.tsx +++ b/apps/web/src/components/agent-triggers/AgentTriggerSection.tsx @@ -45,8 +45,8 @@ export interface AgentTriggerSectionProps { /** * Shared agent / prompt / instruction-page / context-pages form section. * - * Used by both TaskAgentTriggersDialog (per-trigger-type) and - * EventAgentTriggerDialog (single trigger). Selection state lives entirely in + * Used by both TaskAgentTriggersDialog (per-trigger-type) and the calendar + * EventModal's inline agent disclosure. Selection state lives entirely in * the parent through `value`/`onChange` so this component carries no internal * state a remote refetch could clobber. Collapsible "Advanced" matches the * task dialog's UX so users see the same affordance in both surfaces. diff --git a/apps/web/src/components/calendar/AgendaView.tsx b/apps/web/src/components/calendar/AgendaView.tsx index b3bd5179a0..ed59f10b21 100644 --- a/apps/web/src/components/calendar/AgendaView.tsx +++ b/apps/web/src/components/calendar/AgendaView.tsx @@ -11,7 +11,7 @@ import { isYesterday, } from 'date-fns'; import { cn } from '@/lib/utils'; -import { MapPin, Clock, Users, ExternalLink, Calendar } from 'lucide-react'; +import { MapPin, Clock, Users, ExternalLink, Calendar, Zap } from 'lucide-react'; import Link from 'next/link'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Badge } from '@/components/ui/badge'; @@ -186,7 +186,15 @@ function EventCard({ {/* Header */}
-

{event.title}

+

+ {event.hasAgentTrigger && ( + <> +

{event.description && (

{event.description} diff --git a/apps/web/src/components/calendar/CalendarView.tsx b/apps/web/src/components/calendar/CalendarView.tsx index 72544e89b4..1ddb4291e3 100644 --- a/apps/web/src/components/calendar/CalendarView.tsx +++ b/apps/web/src/components/calendar/CalendarView.tsx @@ -246,11 +246,22 @@ export function CalendarView({ context, driveId, driveName: _driveName, classNam color?: string; attendeeIds?: string[]; pageId?: string | null; + agentTrigger?: { + agentPageId: string; + prompt?: string; + instructionPageId: string | null; + contextPageIds: string[]; + } | null; }) => { if (selectedEvent) { await updateEvent(selectedEvent.id, eventData); } else { - await createEvent(eventData); + // POST schema doesn't accept null (only undefined no-op or object upsert) + const { agentTrigger, ...rest } = eventData; + await createEvent({ + ...rest, + agentTrigger: agentTrigger ?? undefined, + }); } setIsEventModalOpen(false); }; diff --git a/apps/web/src/components/calendar/DayView.tsx b/apps/web/src/components/calendar/DayView.tsx index e7ceae3bb7..6f251c81ec 100644 --- a/apps/web/src/components/calendar/DayView.tsx +++ b/apps/web/src/components/calendar/DayView.tsx @@ -8,7 +8,7 @@ import { setMinutes, } from 'date-fns'; import { cn } from '@/lib/utils'; -import { MapPin, Users } from 'lucide-react'; +import { MapPin, Users, Zap } from 'lucide-react'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { CalendarEvent, @@ -236,7 +236,15 @@ function TimedEventCard({ onClick(); }} > -

{event.title}
+
+ {event.hasAgentTrigger && ( + <> +
{format(new Date(event.startAt), 'h:mm a')} - {format(new Date(event.endAt), 'h:mm a')}
@@ -293,7 +301,15 @@ function AllDayEventCard({ )} onClick={onClick} > -
{event.title}
+
+ {event.hasAgentTrigger && ( + <> +
{event.location && (
diff --git a/apps/web/src/components/calendar/EventAgentTriggerDialog.tsx b/apps/web/src/components/calendar/EventAgentTriggerDialog.tsx deleted file mode 100644 index 23176bfcaa..0000000000 --- a/apps/web/src/components/calendar/EventAgentTriggerDialog.tsx +++ /dev/null @@ -1,299 +0,0 @@ -'use client'; - -import { useEffect, useRef, useState } from 'react'; -import { AlertCircle, Bot, Zap } from 'lucide-react'; -import { toast } from 'sonner'; -import useSWR, { mutate as globalMutate } from 'swr'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { Button } from '@/components/ui/button'; -import { Switch } from '@/components/ui/switch'; -import { Label } from '@/components/ui/label'; -import { fetchWithAuth, put, del } from '@/lib/auth/auth-fetch'; -import { useEditingStore } from '@/stores/useEditingStore'; -import { useEditingSession } from '@/stores/useEditingSession'; -import { - AgentTriggerSection, - type AgentTriggerValue, - type AgentTriggerAgent, -} from '@/components/agent-triggers/AgentTriggerSection'; - -interface EventTriggerRow { - id: string; - agentPageId: string; - prompt: string | null; - instructionPageId: string | null; - contextPageIds: string[] | null; - lastFiredAt: string | null; - lastFireError: string | null; - lastRunStatus: 'running' | 'success' | 'error' | 'cancelled' | null; -} - -type LastRunStatus = 'never_run' | 'success' | 'error'; - -const lastRunStatusFor = (row: EventTriggerRow): LastRunStatus => - row.lastFiredAt === null - ? 'never_run' - : row.lastFireError - ? 'error' - : 'success'; - -const statusToneClass = (status: LastRunStatus) => - status === 'error' ? 'text-xs text-destructive' : 'text-xs text-muted-foreground'; - -interface EventAgentTriggerDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - eventId: string; - eventTitle: string; - driveId: string; - onSaved?: () => void; -} - -const EMPTY_VALUE: AgentTriggerValue = { - agentPageId: '', - prompt: '', - instructionPageId: null, - contextPageIds: [], -}; - -const triggerFetcher = async (url: string): Promise<{ trigger: EventTriggerRow | null }> => { - const res = await fetchWithAuth(url); - if (!res.ok) throw new Error('Failed to load trigger'); - return res.json(); -}; - -const agentsFetcher = async (url: string): Promise<{ agents: AgentTriggerAgent[] }> => { - const res = await fetchWithAuth(url); - if (!res.ok) throw new Error('Failed to load agents'); - return res.json(); -}; - -export function EventAgentTriggerDialog({ - open, - onOpenChange, - eventId, - eventTitle, - driveId, - onSaved, -}: EventAgentTriggerDialogProps) { - const triggerKey = open ? `/api/calendar/events/${eventId}/triggers` : null; - const agentsKey = open && driveId ? `/api/drives/${driveId}/agents` : null; - - const isAnyActive = useEditingStore((s) => s.isAnyActive()); - const triggerLoadedRef = useRef(false); - const agentsLoadedRef = useRef(false); - - const { data: triggerData, isLoading: triggerLoading, mutate: refetchTrigger } = useSWR( - triggerKey, - triggerFetcher, - { - revalidateOnFocus: false, - isPaused: () => triggerLoadedRef.current && isAnyActive, - onSuccess: () => { - triggerLoadedRef.current = true; - }, - }, - ); - - const { data: agentsData, isLoading: agentsLoading } = useSWR( - agentsKey, - agentsFetcher, - { - revalidateOnFocus: false, - isPaused: () => agentsLoadedRef.current && isAnyActive, - onSuccess: () => { - agentsLoadedRef.current = true; - }, - }, - ); - - const agents = agentsData?.agents ?? []; - - const [enabled, setEnabled] = useState(false); - const [value, setValue] = useState(EMPTY_VALUE); - const [saving, setSaving] = useState(false); - const [removing, setRemoving] = useState(false); - - useEditingSession(`event-trigger:${eventId}`, open, 'form', { - pageId: eventId, - componentName: 'EventAgentTriggerDialog', - }); - - useEffect(() => { - if (!open) return; - const row = triggerData?.trigger ?? null; - if (row) { - setEnabled(true); - setValue({ - agentPageId: row.agentPageId, - prompt: row.prompt ?? '', - instructionPageId: row.instructionPageId, - contextPageIds: row.contextPageIds ?? [], - }); - } else { - setEnabled(false); - setValue(EMPTY_VALUE); - } - }, [open, triggerData]); - - const existing = triggerData?.trigger ?? null; - const existingStatus: LastRunStatus | null = existing ? lastRunStatusFor(existing) : null; - - const noAgents = !agentsLoading && agents.length === 0; - - const handleSave = async () => { - if (!value.agentPageId) { - toast.error('Pick an agent first'); - return; - } - if (!value.prompt.trim() && !value.instructionPageId) { - toast.error('Enter a prompt or pick an instruction page'); - return; - } - - setSaving(true); - try { - await put(`/api/calendar/events/${eventId}/triggers`, { - agentPageId: value.agentPageId, - prompt: value.prompt.trim() || undefined, - instructionPageId: value.instructionPageId, - contextPageIds: value.contextPageIds, - }); - await refetchTrigger(); - await globalMutate((key: unknown) => - typeof key === 'string' && key.startsWith('/api/calendar/events'), - ); - toast.success('Agent trigger saved'); - onSaved?.(); - } catch (err) { - toast.error(err instanceof Error ? err.message : 'Failed to save trigger'); - } finally { - setSaving(false); - } - }; - - const handleRemove = async () => { - setRemoving(true); - try { - await del(`/api/calendar/events/${eventId}/triggers`); - await refetchTrigger(); - await globalMutate((key: unknown) => - typeof key === 'string' && key.startsWith('/api/calendar/events'), - ); - setEnabled(false); - setValue(EMPTY_VALUE); - toast.success('Trigger removed'); - onSaved?.(); - } catch { - toast.error('Failed to remove trigger'); - } finally { - setRemoving(false); - } - }; - - return ( - - - - - - Agent trigger - - - {eventTitle} - - - - {triggerLoading ? ( -
Loading…
- ) : ( -
- {noAgents && ( -

- No agents in this drive. Create an AI Chat page first. -

- )} - -
-
-
- - -
- -
- -

- The agent runs once when the event begins. Reschedule the event to re-aim the trigger. -

- - {enabled && !noAgents && ( - <> - - - {existingStatus && existingStatus !== 'never_run' && ( -

- {existingStatus === 'error' && ( -

- )} - -
- {existing && ( - - )} - -
- - )} -
-
- )} - - - - -
-
- ); -} diff --git a/apps/web/src/components/calendar/EventModal.tsx b/apps/web/src/components/calendar/EventModal.tsx index 8f01488398..eeb9d93502 100644 --- a/apps/web/src/components/calendar/EventModal.tsx +++ b/apps/web/src/components/calendar/EventModal.tsx @@ -1,12 +1,14 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef, useMemo } from 'react'; import { format } from 'date-fns'; -import { CalendarIcon, Clock, MapPin, Trash2, Zap } from 'lucide-react'; +import { AlertCircle, Bot, CalendarIcon, ChevronRight, Clock, MapPin, Trash2, Zap } from 'lucide-react'; import { toast } from 'sonner'; +import useSWR from 'swr'; import { Dialog, DialogContent, + DialogDescription, DialogHeader, DialogTitle, DialogFooter, @@ -29,10 +31,29 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible'; import { cn } from '@/lib/utils'; +import { fetchWithAuth } from '@/lib/auth/auth-fetch'; +import { useEditingStore } from '@/stores/useEditingStore'; +import { useEditingSession } from '@/stores/useEditingSession'; import { CalendarEvent, EVENT_COLORS } from './calendar-types'; import { TriggerPagePicker } from '@/components/layout/middle-content/page-views/task-list/TriggerPagePicker'; -import { EventAgentTriggerDialog } from './EventAgentTriggerDialog'; +import { + AgentTriggerSection, + type AgentTriggerValue, + type AgentTriggerAgent, +} from '@/components/agent-triggers/AgentTriggerSection'; + +export type AgentTriggerSavePayload = { + agentPageId: string; + prompt?: string; + instructionPageId: string | null; + contextPageIds: string[]; +}; interface EventModalProps { isOpen: boolean; @@ -53,6 +74,7 @@ interface EventModalProps { color?: string; attendeeIds?: string[]; pageId?: string | null; + agentTrigger?: AgentTriggerSavePayload | null; }) => Promise; onDelete?: () => Promise; driveId?: string; @@ -72,6 +94,43 @@ const TIME_OPTIONS = Array.from({ length: 48 }, (_, i) => { const COLOR_OPTIONS = Object.keys(EVENT_COLORS) as (keyof typeof EVENT_COLORS)[]; +interface EventTriggerRow { + id: string; + agentPageId: string; + prompt: string | null; + instructionPageId: string | null; + contextPageIds: string[] | null; + lastFiredAt: string | null; + lastFireError: string | null; +} + +type LastRunStatus = 'never_run' | 'success' | 'error'; + +const lastRunStatusFor = (row: EventTriggerRow): LastRunStatus => { + if (row.lastFiredAt === null) return 'never_run'; + if (row.lastFireError) return 'error'; + return 'success'; +}; + +const EMPTY_AGENT_VALUE: AgentTriggerValue = { + agentPageId: '', + prompt: '', + instructionPageId: null, + contextPageIds: [], +}; + +const triggerFetcher = async (url: string): Promise<{ trigger: EventTriggerRow | null }> => { + const res = await fetchWithAuth(url); + if (!res.ok) throw new Error('Failed to load trigger'); + return res.json(); +}; + +const agentsFetcher = async (url: string): Promise<{ agents: AgentTriggerAgent[] }> => { + const res = await fetchWithAuth(url); + if (!res.ok) throw new Error('Failed to load agents'); + return res.json(); +}; + export function EventModal({ isOpen, onClose, @@ -84,10 +143,10 @@ export function EventModal({ }: EventModalProps) { const isEditing = !!event; const isDriveContext = context === 'drive' && !!driveId; - // Agent triggers live in their own dialog now (parity with TaskAgentTriggersDialog). - // Open it from the "Configure agent trigger" button below — only meaningful for - // saved drive events because the executor needs a drive + a real eventId. - const canManageAgent = isDriveContext && isEditing && !!event; + const isRecurring = !!event?.recurrenceRule; + // Agent triggers attach to drive events only — the executor needs a drive + // context to resolve agent / instruction / context pages. + const showAgentSection = isDriveContext; // Form state const [title, setTitle] = useState(''); @@ -101,7 +160,62 @@ export function EventModal({ const [allDay, setAllDay] = useState(false); const [color, setColor] = useState('default'); const [isSaving, setIsSaving] = useState(false); - const [agentDialogOpen, setAgentDialogOpen] = useState(false); + + // Agent trigger state + const [agentEnabled, setAgentEnabled] = useState(false); + const [agentValue, setAgentValue] = useState(EMPTY_AGENT_VALUE); + const [agentExpanded, setAgentExpanded] = useState(false); + const [advancedExpanded, setAdvancedExpanded] = useState(false); + + // Pause SWR while the modal is open so a remote calendar broadcast can't + // clobber unsaved typing in the form (mirrors the dialog's prior behavior). + useEditingSession(`event-modal:${event?.id ?? 'new'}`, isOpen, 'form', { + pageId: event?.id, + componentName: 'EventModal', + }); + + const isAnyActive = useEditingStore((s) => s.isAnyActive()); + const triggerLoadedRef = useRef(false); + const agentsLoadedRef = useRef(false); + + // Fetch the existing trigger when editing a saved drive event. New events + // (no eventId yet) start from the empty value; the agent trigger is sent + // in the same POST that creates the event. + const triggerKey = + isOpen && showAgentSection && isEditing && event ? `/api/calendar/events/${event.id}/triggers` : null; + const agentsKey = + isOpen && showAgentSection && driveId ? `/api/drives/${driveId}/agents` : null; + + const { data: triggerData, isLoading: triggerLoading } = useSWR( + triggerKey, + triggerFetcher, + { + revalidateOnFocus: false, + isPaused: () => triggerLoadedRef.current && isAnyActive, + onSuccess: () => { + triggerLoadedRef.current = true; + }, + }, + ); + + const { data: agentsData, isLoading: agentsLoading } = useSWR( + agentsKey, + agentsFetcher, + { + revalidateOnFocus: false, + isPaused: () => agentsLoadedRef.current && isAnyActive, + onSuccess: () => { + agentsLoadedRef.current = true; + }, + }, + ); + + const agents = useMemo(() => agentsData?.agents ?? [], [agentsData]); + const noAgents = !agentsLoading && agents.length === 0; + const existingTrigger = triggerData?.trigger ?? null; + const existingStatus: LastRunStatus | null = existingTrigger + ? lastRunStatusFor(existingTrigger) + : null; // Initialize form when modal opens useEffect(() => { @@ -150,9 +264,42 @@ export function EventModal({ setStartTime(format(start, 'HH:mm')); setEndTime(format(end, 'HH:mm')); } + setAdvancedExpanded(false); + setAgentExpanded(false); + } else { + triggerLoadedRef.current = false; + agentsLoadedRef.current = false; } }, [isOpen, event, defaultValues]); + // Hydrate agent state once the trigger fetch lands (or reset if there's no + // trigger / no eventId yet). Recurring events skip hydration because the + // executor rejects triggers on recurring rows — keeping agentEnabled=false + // means save sends agentTrigger=null and removes any defensive stale row. + useEffect(() => { + if (!isOpen) return; + if (existingTrigger && !isRecurring) { + setAgentEnabled(true); + setAgentValue({ + agentPageId: existingTrigger.agentPageId, + prompt: existingTrigger.prompt ?? '', + instructionPageId: existingTrigger.instructionPageId, + contextPageIds: existingTrigger.contextPageIds ?? [], + }); + } else if (!triggerKey || (!triggerLoading && triggerData)) { + // No fetch (new event) OR fetch landed with no trigger row (or with one + // we're ignoring on a recurring event) → reset to empty. + setAgentEnabled(false); + setAgentValue(EMPTY_AGENT_VALUE); + } + }, [isOpen, existingTrigger, isRecurring, triggerKey, triggerLoading, triggerData]); + + const selectedAgentName = useMemo(() => { + if (!agentEnabled) return null; + const found = agents.find((a) => a.id === agentValue.agentPageId); + return found?.title ?? null; + }, [agentEnabled, agents, agentValue.agentPageId]); + const buildDateTime = (date: Date, time: string): Date => { const [hours, minutes] = time.split(':').map(Number); const result = new Date(date); @@ -178,6 +325,37 @@ export function EventModal({ return; } + let agentTrigger: AgentTriggerSavePayload | null | undefined; + if (showAgentSection) { + if (agentEnabled) { + if (isRecurring) { + toast.error('Recurring events can’t have agent triggers'); + return; + } + if (!agentValue.agentPageId) { + toast.error('Pick an agent for the trigger'); + return; + } + if (!agentValue.prompt.trim() && !agentValue.instructionPageId) { + toast.error('Enter a prompt or pick an instruction page'); + return; + } + agentTrigger = { + agentPageId: agentValue.agentPageId, + prompt: agentValue.prompt.trim() || undefined, + instructionPageId: agentValue.instructionPageId, + contextPageIds: agentValue.contextPageIds, + }; + } else if (existingTrigger && !isRecurring) { + // Was enabled, user turned it off → remove. Skip on recurring events: + // the trigger row shouldn't exist there anyway, but if a stale one is + // present we leave it alone rather than silently deleting it on every + // edit. The user never saw an Off→On affordance for it. + agentTrigger = null; + } + // else: undefined → no-op (no trigger before, none now) + } + setIsSaving(true); try { await onSave({ @@ -189,6 +367,7 @@ export function EventModal({ allDay, color, pageId: isDriveContext ? linkedPageId : undefined, + agentTrigger, }); toast.success(isEditing ? 'Event updated' : 'Event created'); onClose(); @@ -218,11 +397,19 @@ export function EventModal({ } }; + let agentHeaderLabel: string; + if (!agentEnabled) agentHeaderLabel = 'Off'; + else if (selectedAgentName) agentHeaderLabel = `${selectedAgentName} at start`; + else agentHeaderLabel = 'On'; + return ( - + {isEditing ? 'Edit Event' : 'New Event'} + + Configure the event title, schedule, location, and optional agent trigger. +
@@ -378,23 +565,6 @@ export function EventModal({
- {/* Linked page (drive events only — events in a drive can carry a doc the way task items do via taskItems.pageId) */} - {isDriveContext && driveId && ( -
- - -

- Optional. Surfaces a related doc on the event. -

-
- )} - {/* Description */}
@@ -407,19 +577,120 @@ export function EventModal({ />
- {/* Agent trigger button — opens dedicated dialog (parity with TaskAgentTriggersDialog). - Only meaningful for saved drive events because the dialog needs an eventId - + drive context to upsert into /api/calendar/events/:id/triggers. */} - {canManageAgent && ( - + {/* Agent trigger — collapsed by default; only meaningful for drive events. + Status text reflects whether a trigger is configured so the modal + reads as a normal calendar by default. */} + {showAgentSection && ( + + + + + + {isRecurring ? ( +

+ Recurring events can't have agent triggers. The cron poller fires one-shot occurrences only. +

+ ) : ( +
+
+
+ + +
+ +
+ + {noAgents && ( +

+ No agents in this drive. Create an AI Chat page first. +

+ )} + + {agentEnabled && !noAgents && driveId && ( + <> + + + {existingStatus && existingStatus !== 'never_run' && ( +

+ {existingStatus === 'error' && ( +

+ )} + + )} +
+ )} +
+
+ )} + + {/* Advanced — Linked page lives here so casual users see only normal + calendar fields. Drive events only because the picker scopes to + the drive's pages. */} + {isDriveContext && driveId && ( + + + + + +
+ + +

+ Attach a doc, sheet, or other page to this event. +

+
+
+
)}
@@ -444,15 +715,6 @@ export function EventModal({ - {canManageAgent && event && driveId && ( - - )} ); } diff --git a/apps/web/src/components/calendar/MobileDayAgenda.tsx b/apps/web/src/components/calendar/MobileDayAgenda.tsx index 6d81c9b194..5d2b32f970 100644 --- a/apps/web/src/components/calendar/MobileDayAgenda.tsx +++ b/apps/web/src/components/calendar/MobileDayAgenda.tsx @@ -3,7 +3,7 @@ import { useMemo } from 'react'; import { format, isToday as checkIsToday, isTomorrow, isYesterday } from 'date-fns'; import { cn } from '@/lib/utils'; -import { MapPin, Clock, Users, Plus, Calendar } from 'lucide-react'; +import { MapPin, Clock, Users, Plus, Calendar, Zap } from 'lucide-react'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -209,6 +209,12 @@ function MobileEventCard({ {/* Title and time */}

+ {event.hasAgentTrigger && ( + <> +

{!event.allDay && ( diff --git a/apps/web/src/components/calendar/MonthView.tsx b/apps/web/src/components/calendar/MonthView.tsx index ec2834c355..aa15260758 100644 --- a/apps/web/src/components/calendar/MonthView.tsx +++ b/apps/web/src/components/calendar/MonthView.tsx @@ -10,6 +10,7 @@ import { format, isSameMonth, } from 'date-fns'; +import { Zap } from 'lucide-react'; import { cn } from '@/lib/utils'; import { CalendarEvent, @@ -201,6 +202,12 @@ function EventPill({ )} onClick={onClick} > + {event.hasAgentTrigger && ( + <> +