feat: Add AI calendar invite editor#169
Conversation
✅ Pre-PR verification — PASS
Agentic verification — local reportAgentic verification wrote local artifacts. Their contents are not posted to GitHub because the verifier can inspect real mailbox/calendar data.
This comment is upserted by |
Greptile SummaryThis PR adds a full AI-powered calendar invite editor to the Calendar sidebar. An extraction service uses an LLM to pull meeting details from an email thread, populates an editable form, handles Google Calendar write-scope re-authentication inline, and finally creates a Google Calendar event. A follow-up commit in the same branch also addressed previously raised review concerns — timezone correctness (floating wall-clock +
Confidence Score: 5/5Safe to merge. The core invite extraction, timezone handling, and calendar creation paths are well-guarded, and all six issues raised in the previous review round appear to be addressed. The new extraction, timezone, and creation logic is thoroughly tested (unit + e2e specs), and all previously flagged multi-day date, stale-lock, reauth-failure, and double-trigger bugs have been fixed. The two remaining findings are minor: a redundant state setter call that has no user-visible effect, and a keyboard shortcut that lacks a guard already applied to its sibling. CalendarPanel.tsx (duplicate error setter,
|
| Filename | Overview |
|---|---|
| src/extensions/mail-ext-calendar/src/renderer/CalendarPanel.tsx | Core UI for the invite editor. Adds ~900 lines for InviteEditor component, startInvite/cancelInvite/createInvite callbacks, and proposed-event preview. Contains a duplicate setInviteError call in startInvite (lines 1034–1036 and 1057–1059) and the i shortcut can re-trigger extraction when the editor is open and focus is on a non-input element. |
| src/main/services/calendar-invite.ts | New LLM extraction service. Prompt safety via wrapUntrustedEmail/UNTRUSTED_DATA_INSTRUCTION, retry logic, deterministic conference requestId derivation, and a well-structured demo fallback. No issues found. |
| src/main/ipc/calendar.ipc.ts | Adds three new IPC handlers (get-invite-options, extract-invite, create-invite) and demo-mode stubs. Server-side writability re-check before insert, Zod schema validation of incoming draft, and proper broadcast of calendar updates. No issues found. |
| src/shared/calendar-timezone.ts | New timezone utility. Converts between floating wall-clock strings and instants using Intl.DateTimeFormat. Correctly handles DST edges (documented caveat for preview only), explicit-offset LLM output, and bare wall-clock interpretation in user zone. No issues found. |
| src/shared/calendar-invite-editor.ts | Editor helpers — combineDateAndTime, updateInviteStartDate (preserves multi-day duration), updateInviteEndTime (preserves end date, rolls midnight), shouldStartInviteExtraction, isStaleInviteRequest. Correctly avoids Date.getTime() for wall-clock arithmetic. No issues found. |
| src/renderer/hooks/useKeyboardShortcuts.ts | Adds i shortcut to start a calendar invite and guards b when invite is open. The i shortcut lacks the same guard as b, so it can re-trigger extraction when the editor is open and a non-input element has focus. |
| src/renderer/components/EmailPreviewSidebar.tsx | Adds invite-pending detection to hide tab bar while editor is open, forces email tab on invite open, and clears stale calendarInviteRequest when the user navigates to a different thread. No issues found. |
Sequence Diagram
%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
actor User
participant KS as useKeyboardShortcuts
participant Store as AppStore
participant ESP as EmailPreviewSidebar
participant CP as CalendarPanel
participant IPC as calendar.ipc (main)
participant LLM as LLM Service
participant GCal as Google Calendar API
User->>KS: Press "i"
KS->>Store: startCalendarInvite(emailId, threadId, nonce)
Store-->>ESP: calendarInviteRequest set → hide tab bar, force email tab
Store-->>CP: calendarInviteRequest set
CP->>CP: shouldStartInviteExtraction → true
CP->>IPC: calendar:extract-invite(emailId)
IPC->>IPC: getEmail + getEmailsByThread
IPC->>IPC: getInviteCalendarOptions()
IPC->>LLM: extractCalendarInviteDraft(threadEmails, calendars)
LLM-->>IPC: raw JSON draft
IPC->>IPC: parseCalendarInviteDraft → normalizeToCalendarWallClock
IPC-->>CP: "{ draft, calendars, requiresReauth }"
CP->>CP: render InviteEditor (pre-filled)
alt User edits and submits
User->>CP: Edit fields, click Create and send
CP->>CP: validateCalendarInviteDraft (client-side)
CP->>IPC: calendar:create-invite(accountId, draft)
IPC->>IPC: CalendarInviteDraftSchema.safeParse + server validate
IPC->>GCal: insertCalendarEvent(params)
GCal-->>IPC: created event
IPC->>IPC: saveCalendarEvents + calendarSyncService.syncNow()
IPC-->>CP: "{ success: true, event }"
CP->>Store: clearCalendarInviteRequest()
Store-->>ESP: tab bar restored
else Missing write scope
IPC-->>CP: "{ requiresReauth: true }"
User->>CP: Click Re-authenticate
CP->>IPC: window.api.auth.reauth(accountId)
IPC-->>CP: success
CP->>IPC: calendar:get-invite-options
IPC-->>CP: updated calendars (writable: true)
end
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
actor User
participant KS as useKeyboardShortcuts
participant Store as AppStore
participant ESP as EmailPreviewSidebar
participant CP as CalendarPanel
participant IPC as calendar.ipc (main)
participant LLM as LLM Service
participant GCal as Google Calendar API
User->>KS: Press "i"
KS->>Store: startCalendarInvite(emailId, threadId, nonce)
Store-->>ESP: calendarInviteRequest set → hide tab bar, force email tab
Store-->>CP: calendarInviteRequest set
CP->>CP: shouldStartInviteExtraction → true
CP->>IPC: calendar:extract-invite(emailId)
IPC->>IPC: getEmail + getEmailsByThread
IPC->>IPC: getInviteCalendarOptions()
IPC->>LLM: extractCalendarInviteDraft(threadEmails, calendars)
LLM-->>IPC: raw JSON draft
IPC->>IPC: parseCalendarInviteDraft → normalizeToCalendarWallClock
IPC-->>CP: "{ draft, calendars, requiresReauth }"
CP->>CP: render InviteEditor (pre-filled)
alt User edits and submits
User->>CP: Edit fields, click Create and send
CP->>CP: validateCalendarInviteDraft (client-side)
CP->>IPC: calendar:create-invite(accountId, draft)
IPC->>IPC: CalendarInviteDraftSchema.safeParse + server validate
IPC->>GCal: insertCalendarEvent(params)
GCal-->>IPC: created event
IPC->>IPC: saveCalendarEvents + calendarSyncService.syncNow()
IPC-->>CP: "{ success: true, event }"
CP->>Store: clearCalendarInviteRequest()
Store-->>ESP: tab bar restored
else Missing write scope
IPC-->>CP: "{ requiresReauth: true }"
User->>CP: Click Re-authenticate
CP->>IPC: window.api.auth.reauth(accountId)
IPC-->>CP: success
CP->>IPC: calendar:get-invite-options
IPC-->>CP: updated calendars (writable: true)
end
Reviews (11): Last reviewed commit: "Preserve calendar list when post-reauth ..." | Re-trigger Greptile
|
very cool! yeah you can use /reviewloop to satisfy the bots but iv been meaning to do this for a while. |
|
i like this feature but there are a bunch of changes in this PR that are unrelated to the feature so please move those to their own PRs. |
|
Want your agent to iterate on Greptile's feedback? Try greploops. |
Timezone correctness:
- Represent draft start/end as floating wall-clock in the draft's own
timezone (what Google's { dateTime, timeZone } pair consumes) instead of
UTC instants, removing the editor's local-zone round-trip that shifted
times when the calendar zone differed from the browser zone.
- Add src/shared/calendar-timezone.ts: convert an extracted value into the
calendar wall-clock. Explicit-offset times ("2pm London", +01:00) are
converted to the calendar zone; bare times fall back to the user's
physical zone, then are expressed in the calendar zone.
- Update the extraction prompt to emit an offset only when the email states
a zone (floating otherwise) so code, not the LLM, does conversions.
Invite UX is now modal:
- Hide the sidebar tab bar and calendar date-nav while the invite is open;
suppress the "b" tab-switch shortcut. Previously these stayed visible and
appeared clickable but were silently reverted, reading as broken.
- Add an explicit Close (X) button to the editor header.
Google Meet clarity:
- When Google Meet is selected, show "A Google Meet link will be created and
added to the invite" instead of an empty disabled field.
- Derive a stable conference requestId from the draft so a retried create
reuses the same Meet conference rather than minting a duplicate.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Note that the single offset correction is preview-only and the imprecision at DST transitions never reaches the event sent to Google. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ExtensionPanelSlot hardcoded `extensionId === "calendar" && panelId === "day-view"` to suppress its generic title bar — calendar-specific knowledge in a generic extension primitive. Replace it with an `ownHeader` capability declared in the panel manifest (SidebarPanelContributionSchema), threaded through registration and getSidebarPanels to the slot. The calendar day-view sets `ownHeader: true`; any panel that renders its own chrome can now opt in without the slot knowing which extension it is. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
From code-review feedback. These are the in-scope correctness/security fixes; the larger semantic-pipeline redesign is tracked separately as a follow-up. - updateInviteStartDate: don't blank the end time when the existing end has no parseable time — fall back to a 30-minute duration off the new start. - updateInviteEndTime: roll the end to the next day when the chosen end time lands at/before the start (events crossing midnight). - chooseDefaultCalendar: never default to a read-only calendar; return null so validation/re-auth handles the no-writable-calendar case. - Extraction prompt: mark calendar records as data, not instructions (shared/ subscribed calendar names are attacker-influenceable); the chosen calendarId is already re-validated server-side before any Google API call. - Extraction request: set temperature 0 for deterministic structured output. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Code-review follow-up: now vs. deferredA detailed review proposed shifting the invite extractor toward a "LLM as semantic judge over structured facts; deterministic code resolves/validates/blocks" architecture. The core thesis is right, and I split the response: Landed in this PR (commit
|
The screenshots referenced in the PR body 404'd — they pointed at a now-gone repo (mickn/exo-calendar-invite-pr). Re-capture from the demo app and commit them under docs/pr-assets/ on this branch so the references are stable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Greptile caught a soft-lock introduced by the modal invite UX: pressing `i` on thread A then selecting an email in thread B before extraction starts leaves calendarInviteRequest non-null with no invite editor rendered — the sidebar tab bar stays hidden and `b` stays suppressed with no reachable exit. EmailPreviewSidebar (always mounted) now clears the request when it targets a thread other than the selected one, via a new isStaleInviteRequest predicate (keyed on a confirmed different selected thread so it can't race the legitimate same-thread start). CalendarPanel's effect is left to only start same-thread requests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Greptile P1: reauthenticateInviteCalendar wiped inviteCalendars and reset requiresReauth to false before checking whether getInviteOptions succeeded. On a failed fetch this emptied the dropdown AND hid the Re-authenticate button, stranding the user with no writable calendar and no retry path. Now the calendar list and re-auth state are only updated on a successful fetch; on failure we surface the error and leave prior state intact. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
fyi: all comments are resolved, and non-relevant changes moved out of this pr |
Summary
This adds an AI-powered calendar invite editor to the Calendar sidebar. From a scheduling email, Exo can extract the likely invite automatically, fill the editable fields, and let the user review everything before creating the Google Calendar event.
The extraction pulls out the title, guests, date, time, meeting type, location, notes, and target calendar. If calendar write permission is missing, the sidebar shows the re-authentication state directly in the invite flow.
Screenshots
All screenshots use demo data.
Changes
This PR adds
https://www.googleapis.com/auth/calendar.eventsto the OAuth scopes requested at sign-in (needed to create events / Google Meet conferences). Because the scope is new, existing accounts must re-authenticate before they can create invites — their current tokens only carry the read scope. The invite flow detects this and shows an inline "Re-authenticate" prompt rather than failing silently, but it is a one-time re-consent for every already-signed-in user. Read-only calendar viewing is unaffected in the interim.Revision (timezone correctness + modal UX + Meet clarity)
A follow-up commit on this branch addresses review feedback and a timezone bug:
start/endare now floating wall-clock strings interpreted in the draft's own timezone (the representation Google's{ dateTime, timeZone }consumes), instead of UTC instants. This removes a bug where editing a time converted it in the browser's zone but stamped it with the calendar's zone, shifting the event when the two differed. Newsrc/shared/calendar-timezone.tsconverts at the boundaries: explicit-offset times from the email (e.g. "2pm London") are normalized into the calendar zone; bare times fall back to the user's physical zone, then are expressed in the calendar zone. The extraction prompt now emits an offset only when the email states a zone, leaving conversions to code.btab-switch shortcut is suppressed, with an explicit Close (×) button in the header. Previously those controls stayed visible but were silently reverted, so they read as broken when clicked.requestIdis derived deterministically from the draft so a retried create reuses the same conference instead of minting a duplicate.Validation
npm run buildnpm run typechecknpm run lintnpm exec -- playwright test --project=unit tests/unit/runtime-flags.spec.ts tests/unit/calendar-invite.spec.tsnpm exec -- playwright test --project=e2e tests/e2e/calendar-invite.spec.tsnpm run pre-pr -- --quick --no-inject --no-commentPre-PR verdict: PASS
full20bda0f