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.hasAgentTrigger && (
+ <>
+
+ Agent trigger configured.
+ >
+ )}
{event.title}
{!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 && (
+ <>
+
+
Agent trigger configured.
+ >
+ )}
{!isAllDay &&
{startTime}}
{event.title}
diff --git a/apps/web/src/components/calendar/WeekView.tsx b/apps/web/src/components/calendar/WeekView.tsx
index a6c5ad3712..b898923054 100644
--- a/apps/web/src/components/calendar/WeekView.tsx
+++ b/apps/web/src/components/calendar/WeekView.tsx
@@ -11,6 +11,7 @@ import {
setHours,
setMinutes,
} from 'date-fns';
+import { Zap } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
CalendarEvent,
@@ -223,7 +224,15 @@ export function WeekView({ currentDate, events, tasks, handlers, driveColorMap,
handlers.onEventClick(event);
}}
>
-
{event.title}
+
+ {event.hasAgentTrigger && (
+ <>
+
+ Agent trigger configured.
+ >
+ )}
+ {event.title}
+
{height > 30 && (
{format(new Date(event.startAt), 'h:mm a')}
@@ -281,6 +290,12 @@ function AllDayEventPill({
)}
onClick={onClick}
>
+ {event.hasAgentTrigger && (
+ <>
+
+ Agent trigger configured.
+ >
+ )}
{event.title}
);
diff --git a/apps/web/src/components/calendar/__tests__/EventAgentTriggerDialog.test.tsx b/apps/web/src/components/calendar/__tests__/EventAgentTriggerDialog.test.tsx
deleted file mode 100644
index cb1414b189..0000000000
--- a/apps/web/src/components/calendar/__tests__/EventAgentTriggerDialog.test.tsx
+++ /dev/null
@@ -1,124 +0,0 @@
-import { describe, test, beforeEach, vi } from 'vitest';
-import { render, screen, waitFor, act } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { SWRConfig, mutate as globalMutate } from 'swr';
-import { assert } from '@/stores/__tests__/riteway';
-import { useEditingStore } from '@/stores/useEditingStore';
-
-vi.mock('sonner', () => ({
- toast: { success: vi.fn(), error: vi.fn() },
-}));
-
-vi.mock('@/lib/auth/auth-fetch', () => ({
- fetchWithAuth: vi.fn(),
- put: vi.fn(),
- del: vi.fn(),
-}));
-
-import { fetchWithAuth } from '@/lib/auth/auth-fetch';
-import { EventAgentTriggerDialog } from '../EventAgentTriggerDialog';
-
-const EVENT_ID = 'evt-1';
-const DRIVE_ID = 'drive-1';
-const TRIGGER_URL = `/api/calendar/events/${EVENT_ID}/triggers`;
-
-const remoteTrigger = () => ({
- id: 'trig-1',
- agentPageId: 'agent-1',
- prompt: 'remote-prompt',
- instructionPageId: null,
- contextPageIds: [],
- lastFiredAt: null,
- lastFireError: null,
- lastRunStatus: null,
-});
-
-const remoteAgent = () => ({ id: 'agent-1', title: 'Triage Bot' });
-
-const cannedFetch = (triggerOverride?: unknown) =>
- vi.mocked(fetchWithAuth).mockImplementation(async (...args: unknown[]) => {
- const url = String(args[0]);
- if (url.endsWith('/triggers')) {
- return new Response(JSON.stringify({ trigger: triggerOverride ?? remoteTrigger() }), {
- status: 200,
- headers: { 'Content-Type': 'application/json' },
- });
- }
- if (url.endsWith('/agents')) {
- return new Response(JSON.stringify({ agents: [remoteAgent()] }), {
- status: 200,
- headers: { 'Content-Type': 'application/json' },
- });
- }
- return new Response('{}', { status: 200 });
- });
-
-const renderDialog = () =>
- render(
- new Map(), dedupingInterval: 0 }}>
- {}}
- eventId={EVENT_ID}
- eventTitle="Standup"
- driveId={DRIVE_ID}
- />
- ,
- );
-
-describe('EventAgentTriggerDialog — editing-store contract', () => {
- beforeEach(() => {
- vi.clearAllMocks();
- useEditingStore.getState().clearAllSessions();
- cannedFetch();
- });
-
- test('preserves in-flight prompt text across a remote refetch', async () => {
- renderDialog();
-
- const promptInput = (await waitFor(() =>
- screen.getByDisplayValue('remote-prompt'),
- )) as HTMLTextAreaElement;
-
- await userEvent.clear(promptInput);
- await userEvent.type(promptInput, 'in-flight typing');
-
- assert({
- given: 'the user has typed into the prompt textarea while the dialog is open',
- should: 'show the typed text in the textarea (sanity check before remote refetch)',
- actual: promptInput.value,
- expected: 'in-flight typing',
- });
-
- await act(async () => {
- await globalMutate(TRIGGER_URL);
- await new Promise((r) => setTimeout(r, 50));
- });
-
- assert({
- given: 'a remote calendar broadcast arrives while the dialog is open with unsaved prompt text',
- should: 'preserve the in-progress prompt text rather than refetching and clobbering it',
- actual: promptInput.value,
- expected: 'in-flight typing',
- });
- });
-
- test('shows the empty (no-trigger) state when the GET returns trigger=null', async () => {
- cannedFetch(null);
-
- renderDialog();
-
- await waitFor(() => {
- // The "Run agent at event start" label is present even when no trigger
- // exists yet; the form expands once the user toggles the switch.
- screen.getByText(/Run agent at event start/i);
- });
-
- assert({
- given: 'no trigger exists on this event yet',
- should: 'render the toggle in its disabled-form state (no Save button visible)',
- actual: screen.queryByRole('button', { name: /^Save$/i }),
- expected: null,
- });
- });
-});
diff --git a/apps/web/src/components/calendar/__tests__/EventModal.test.tsx b/apps/web/src/components/calendar/__tests__/EventModal.test.tsx
new file mode 100644
index 0000000000..aca82d66b0
--- /dev/null
+++ b/apps/web/src/components/calendar/__tests__/EventModal.test.tsx
@@ -0,0 +1,364 @@
+import { describe, test, beforeEach, vi } from 'vitest';
+import { render, screen, waitFor, act } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { SWRConfig, mutate as globalMutate } from 'swr';
+import { assert } from '@/stores/__tests__/riteway';
+import { useEditingStore } from '@/stores/useEditingStore';
+import { CalendarEvent } from '../calendar-types';
+
+vi.mock('sonner', () => ({
+ toast: { success: vi.fn(), error: vi.fn() },
+}));
+
+vi.mock('@/lib/auth/auth-fetch', () => ({
+ fetchWithAuth: vi.fn(),
+ put: vi.fn(),
+ del: vi.fn(),
+ post: vi.fn(),
+ patch: vi.fn(),
+}));
+
+// TriggerPagePicker fetches from auth-fetch internally; stub it so the
+// Advanced disclosure doesn't crash trying to load pages mid-test.
+vi.mock(
+ '@/components/layout/middle-content/page-views/task-list/TriggerPagePicker',
+ () => ({
+ TriggerPagePicker: () => null,
+ }),
+);
+
+import { fetchWithAuth } from '@/lib/auth/auth-fetch';
+import { EventModal } from '../EventModal';
+
+const DRIVE_ID = 'drive-1';
+const EVENT_ID = 'evt-1';
+const TRIGGER_URL = `/api/calendar/events/${EVENT_ID}/triggers`;
+
+const remoteAgent = () => ({ id: 'agent-1', title: 'Triage Bot' });
+
+const remoteTrigger = () => ({
+ id: 'trig-1',
+ agentPageId: 'agent-1',
+ prompt: 'remote-prompt',
+ instructionPageId: null,
+ contextPageIds: [],
+ lastFiredAt: null,
+ lastFireError: null,
+});
+
+const baseEvent = (overrides: Partial = {}): CalendarEvent => ({
+ id: EVENT_ID,
+ driveId: DRIVE_ID,
+ createdById: 'user-1',
+ pageId: null,
+ title: 'Standup',
+ description: null,
+ location: null,
+ startAt: '2026-05-04T15:00:00Z',
+ endAt: '2026-05-04T16:00:00Z',
+ allDay: false,
+ timezone: 'UTC',
+ recurrenceRule: null,
+ visibility: 'DRIVE',
+ color: 'default',
+ syncedFromGoogle: false,
+ googleSyncReadOnly: null,
+ createdAt: '2026-05-04T00:00:00Z',
+ updatedAt: '2026-05-04T00:00:00Z',
+ createdBy: { id: 'user-1', name: 'Test', image: null },
+ attendees: [],
+ ...overrides,
+});
+
+const cannedFetch = (opts: { trigger?: unknown; agents?: unknown } = {}) =>
+ vi.mocked(fetchWithAuth).mockImplementation(async (...args: unknown[]) => {
+ const url = String(args[0]);
+ if (url.endsWith('/triggers')) {
+ return new Response(
+ JSON.stringify({ trigger: opts.trigger === undefined ? remoteTrigger() : opts.trigger }),
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
+ );
+ }
+ if (url.endsWith('/agents')) {
+ return new Response(
+ JSON.stringify({ agents: opts.agents ?? [remoteAgent()] }),
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
+ );
+ }
+ return new Response('{}', { status: 200 });
+ });
+
+interface RenderOpts {
+ event?: CalendarEvent | null;
+ context?: 'user' | 'drive';
+ driveId?: string;
+ onSave?: (data: unknown) => Promise;
+}
+
+const renderModal = (opts: RenderOpts = {}) => {
+ const onSave = opts.onSave ?? (async () => undefined);
+ return render(
+ new Map(), dedupingInterval: 0 }}>
+ {}}
+ event={opts.event ?? null}
+ defaultValues={null}
+ onSave={onSave}
+ driveId={opts.driveId ?? DRIVE_ID}
+ context={opts.context ?? 'drive'}
+ />
+ ,
+ );
+};
+
+const firstSavePayload = async (
+ onSave: ReturnType,
+): Promise<{ agentTrigger?: unknown }> => {
+ await waitFor(() => {
+ if (!onSave.mock.calls.length) throw new Error('onSave not called');
+ });
+ return onSave.mock.calls[0][0] as { agentTrigger?: unknown };
+};
+
+describe('EventModal — agent trigger disclosure', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ useEditingStore.getState().clearAllSessions();
+ cannedFetch();
+ });
+
+ test('hides agent disclosure entirely for personal events', async () => {
+ renderModal({ context: 'user', driveId: undefined, event: null });
+
+ await waitFor(() => {
+ screen.getByText(/New Event/i);
+ });
+
+ assert({
+ given: 'a personal calendar context (no driveId)',
+ should: 'not render the Agent trigger disclosure button',
+ actual: screen.queryByRole('button', { name: /Agent trigger/i }),
+ expected: null,
+ });
+ });
+
+ test('renders agent disclosure collapsed by default with Off label', async () => {
+ cannedFetch({ trigger: null });
+
+ renderModal({ event: null });
+
+ const trigger = await screen.findByRole('button', { name: /Agent trigger\s*Off/i });
+
+ assert({
+ given: 'a new drive event with no trigger configured',
+ should: 'render the disclosure header collapsed and labeled "Off"',
+ actual: !!trigger,
+ expected: true,
+ });
+
+ assert({
+ given: 'a collapsed disclosure',
+ should: 'not show the inline AgentTriggerSection (Run agent at event start label hidden)',
+ actual: screen.queryByText(/Run agent at event start/i),
+ expected: null,
+ });
+ });
+
+ test('prefills agent value and header label when editing an event with an existing trigger', async () => {
+ renderModal({ event: baseEvent() });
+
+ await waitFor(() => {
+ screen.getByRole('button', { name: /Agent trigger\s*Triage Bot at start/i });
+ });
+
+ assert({
+ given: 'an event whose trigger row resolves to agent "Triage Bot"',
+ should: 'show that agent name in the disclosure header',
+ actual: !!screen.queryByRole('button', { name: /Triage Bot at start/i }),
+ expected: true,
+ });
+ });
+
+ test('shows recurring-event note instead of the form when event is recurring', async () => {
+ cannedFetch({ trigger: null });
+
+ renderModal({
+ event: baseEvent({
+ recurrenceRule: { frequency: 'WEEKLY', interval: 1 },
+ }),
+ });
+
+ const triggerHeader = await screen.findByRole('button', { name: /Agent trigger/i });
+ await userEvent.click(triggerHeader);
+
+ await waitFor(() => {
+ screen.getByText(/Recurring events can.+t have agent triggers/i);
+ });
+
+ assert({
+ given: 'a recurring drive event with the Agent trigger disclosure expanded',
+ should: 'show the "can\'t have agent triggers" note instead of the form',
+ actual: screen.queryByText(/Run agent at event start/i),
+ expected: null,
+ });
+ });
+
+ test('Advanced section is collapsed by default and hides Linked page until expanded', async () => {
+ cannedFetch({ trigger: null });
+
+ renderModal({ event: null });
+
+ const advancedTrigger = await screen.findByRole('button', { name: /Advanced/i });
+
+ assert({
+ given: 'a drive event modal',
+ should: 'collapse the Advanced section by default so casual users see only normal calendar fields',
+ actual: !!advancedTrigger,
+ expected: true,
+ });
+
+ // Linked page label should not be visible while collapsed.
+ assert({
+ given: 'a collapsed Advanced section',
+ should: 'not surface the Linked page label',
+ actual: screen.queryByText(/^Linked page$/i),
+ expected: null,
+ });
+ });
+
+ test('saving with agent enabled forwards a complete agentTrigger payload to onSave', async () => {
+ const onSave = vi.fn<(data: unknown) => Promise>(async () => undefined);
+ renderModal({ event: baseEvent(), onSave });
+
+ const header = await screen.findByRole('button', { name: /Triage Bot at start/i });
+ await userEvent.click(header);
+
+ await screen.findByDisplayValue('remote-prompt');
+
+ await userEvent.click(screen.getByRole('button', { name: /^Update$/i }));
+
+ const payload = await firstSavePayload(onSave);
+
+ assert({
+ given: 'an event with an existing trigger and the user clicks Update without changing anything',
+ should: 'call onSave with an agentTrigger object that mirrors the existing trigger (idempotent upsert)',
+ actual: payload.agentTrigger,
+ expected: {
+ agentPageId: 'agent-1',
+ prompt: 'remote-prompt',
+ instructionPageId: null,
+ contextPageIds: [],
+ },
+ });
+ });
+
+ test('saving with toggle off + existing trigger forwards agentTrigger=null to onSave', async () => {
+ const onSave = vi.fn<(data: unknown) => Promise>(async () => undefined);
+ renderModal({ event: baseEvent(), onSave });
+
+ const header = await screen.findByRole('button', { name: /Triage Bot at start/i });
+ await userEvent.click(header);
+
+ // Wait for the form to render then flip the enable toggle off
+ const toggle = await screen.findByRole('switch', { name: /Run agent at event start/i });
+ await userEvent.click(toggle);
+
+ await userEvent.click(screen.getByRole('button', { name: /^Update$/i }));
+
+ const payload = await firstSavePayload(onSave);
+
+ assert({
+ given: 'an existing trigger with the toggle flipped off',
+ should: 'call onSave with agentTrigger=null so the API removes the trigger',
+ actual: payload.agentTrigger,
+ expected: null,
+ });
+ });
+
+ test('a recurring event with a stale trigger row leaves it alone on save (agentTrigger=undefined)', async () => {
+ // Defensive case: the API rejects upserting triggers on recurring events,
+ // so a recurring row with a trigger should never exist. If one does, the
+ // modal must not surface it as enabled (would trap the user behind a
+ // recurring-validation error) and must not silently send agentTrigger=null
+ // either (that would delete the row without user intent).
+ const onSave = vi.fn<(data: unknown) => Promise>(async () => undefined);
+
+ renderModal({
+ event: baseEvent({
+ recurrenceRule: { frequency: 'WEEKLY', interval: 1 },
+ }),
+ onSave,
+ });
+
+ // Disclosure header should read 'Off' even though a trigger row exists,
+ // because hydration skips recurring events.
+ await screen.findByRole('button', { name: /Agent trigger\s*Off/i });
+
+ await userEvent.click(screen.getByRole('button', { name: /^Update$/i }));
+
+ const payload = await firstSavePayload(onSave);
+
+ assert({
+ given: 'a recurring event with a stale trigger row',
+ should: 'send agentTrigger=undefined (no-op) so the broken row is left alone, not deleted',
+ actual: payload.agentTrigger,
+ expected: undefined,
+ });
+ });
+
+ test('saving on a personal event omits agentTrigger entirely', async () => {
+ const onSave = vi.fn<(data: unknown) => Promise>(async () => undefined);
+ renderModal({ context: 'user', driveId: undefined, event: null, onSave });
+
+ // Title is required
+ const titleInput = await screen.findByLabelText(/Title/i);
+ await userEvent.type(titleInput, 'Solo lunch');
+
+ await userEvent.click(screen.getByRole('button', { name: /^Create$/i }));
+
+ const payload = await firstSavePayload(onSave);
+
+ assert({
+ given: 'a personal calendar create flow',
+ should: 'omit agentTrigger from the onSave payload (undefined, not null)',
+ actual: payload.agentTrigger,
+ expected: undefined,
+ });
+ });
+
+ test('preserves in-flight prompt text across a remote refetch (editing-store contract)', async () => {
+ renderModal({ event: baseEvent() });
+
+ // Disclosure auto-expands when there's an existing trigger? No — it stays collapsed
+ // by design. Open it.
+ const header = await screen.findByRole('button', { name: /Agent trigger/i });
+ await userEvent.click(header);
+
+ const promptInput = (await waitFor(() =>
+ screen.getByDisplayValue('remote-prompt'),
+ )) as HTMLTextAreaElement;
+
+ await userEvent.clear(promptInput);
+ await userEvent.type(promptInput, 'in-flight typing');
+
+ assert({
+ given: 'the user has typed into the prompt textarea while the modal is open',
+ should: 'show the typed text (sanity check before refetch)',
+ actual: promptInput.value,
+ expected: 'in-flight typing',
+ });
+
+ await act(async () => {
+ await globalMutate(TRIGGER_URL);
+ await new Promise((r) => setTimeout(r, 50));
+ });
+
+ assert({
+ given: 'a remote calendar broadcast arrives while the modal is open with unsaved prompt text',
+ should: 'preserve the in-progress prompt rather than clobbering it with the refetched value',
+ actual: promptInput.value,
+ expected: 'in-flight typing',
+ });
+ });
+});
diff --git a/apps/web/src/components/calendar/useCalendarData.ts b/apps/web/src/components/calendar/useCalendarData.ts
index f48d75d856..6b903dcc7e 100644
--- a/apps/web/src/components/calendar/useCalendarData.ts
+++ b/apps/web/src/components/calendar/useCalendarData.ts
@@ -124,6 +124,12 @@ export function useCalendarData({
color?: string;
attendeeIds?: string[];
pageId?: string | null;
+ agentTrigger?: {
+ agentPageId: string;
+ prompt?: string;
+ instructionPageId: string | null;
+ contextPageIds: string[];
+ };
}) => {
const result = await post('/api/calendar/events', {
driveId: context === 'drive' ? driveId : null,
@@ -141,7 +147,20 @@ export function useCalendarData({
// Update event - patch() returns parsed JSON directly, throws on error
const updateEvent = useCallback(
- async (eventId: string, updates: Partial> & { startAt?: Date | string; endAt?: Date | string }) => {
+ async (
+ eventId: string,
+ updates: Partial> & {
+ startAt?: Date | string;
+ endAt?: Date | string;
+ // agentTrigger has three states: undefined no-op, null remove, object upsert
+ agentTrigger?: {
+ agentPageId: string;
+ prompt?: string;
+ instructionPageId: string | null;
+ contextPageIds: string[];
+ } | null;
+ },
+ ) => {
const result = await patch(`/api/calendar/events/${eventId}`, {
...updates,
startAt: updates.startAt ? (updates.startAt instanceof Date ? updates.startAt.toISOString() : updates.startAt) : undefined,