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 an AI-powered calendar invite editor to the sidebar: pressing
Confidence Score: 5/5Safe to merge. The invite creation flow, timezone handling, and modal locking are all implemented correctly; every regression flagged in the previous review round has been addressed with dedicated tests. The feature is well-scoped: LLM extraction, timezone conversion, multi-day event editing, re-authentication, and keyboard modal locking are each handled carefully and covered by unit and e2e tests. The only new finding is a demo-mode sort inconsistency caused by mixing UTC ISO strings with floating wall-clock strings, which has no effect on real event creation or real users. src/main/ipc/calendar.ipc.ts — draftToDemoEvent stores floating wall-clock strings while sample events use UTC ISO strings, causing an incorrect lexicographic sort order in non-UTC environments (demo only).
|
| Filename | Overview |
|---|---|
| src/extensions/mail-ext-calendar/src/renderer/CalendarPanel.tsx | Core of the feature: adds invite state machine (extracting → ready → creating), InviteEditor component, proposed-event preview block, and modal locking of date nav/tab bar. The previous-thread issues (double-trigger, multi-day end date collapse, stale invite lock, calendar list wipe) are all addressed. |
| src/main/ipc/calendar.ipc.ts | Adds calendar:extract-invite, calendar:create-invite, and calendar:get-invite-options IPC handlers. Demo mode has a sort inconsistency (sample events emit UTC ISO strings; demoCreatedEvents stores floating wall-clock strings), producing potentially incorrect overlap-column order in non-UTC environments. Real-event path is correct. |
| src/main/services/calendar-invite.ts | New service: LLM extraction with retry, timeout handling, structured-output JSON schema, and demo fallback draft. Prompt-injection guard via UNTRUSTED_DATA_INSTRUCTION, per-provider timeouts, and a deterministic Meet requestId via FNV-1a hash. |
| src/shared/calendar-timezone.ts | New module: floating wall-clock ↔ UTC instant conversions with DST-aware two-pass fixed-point. Handles midnight normalization, spring-forward gaps, and fall-back ambiguity with a well-commented residual caveat. Well tested. |
| src/shared/calendar-invite-editor.ts | Pure wall-clock arithmetic helpers: updateInviteStartDate preserves multi-day duration, updateInviteEndTime preserves end date, updateInviteStartTime shifts end to maintain duration. Addresses all previously flagged multi-day edge cases. |
| src/shared/calendar-invite.ts | Parsing, normalization, validation, and Google Calendar params building. Deterministic conferenceData requestId prevents duplicate Meet links on retry. |
| src/renderer/hooks/useKeyboardShortcuts.ts | Adds 'invite' keyboard mode that suppresses single-key thread shortcuts while the editor is open; Escape cancels the invite via the global handler rather than a second listener in CalendarPanel. |
| src/renderer/components/EmailPreviewSidebar.tsx | Hides tab bar while invite is pending, forces email tab on invite start (edge-triggered), and clears stale invite requests via isStaleInviteRequest when the user navigates to a different thread before CalendarPanel mounts. |
| src/extensions/mail-ext-calendar/src/google-calendar-client.ts | Adds insertCalendarEvent, getCalendarList now returns writable flag based on token scopes and access role, and hasCalendarWriteScope helper reuses the already-loaded OAuth client. |
| src/shared/calendar-date.ts | New utility: isoDateMatchesCalendarDate correctly handles timezone-aware comparison, replacing the old startsWith approach that broke for UTC-behind users. |
| tests/unit/calendar-invite-review-regressions.spec.ts | New regression test suite covering the six issues flagged in the previous review: UTC date matching, multi-day end-time preservation, multi-day duration preservation, stale request detection, midnight rollover, and duration-preserving start-time shift. |
Sequence Diagram
%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant User
participant KBShortcuts as useKeyboardShortcuts
participant Store as AppStore
participant Sidebar as EmailPreviewSidebar
participant Panel as CalendarPanel
participant IPC as calendar.ipc (main)
participant GCal as Google Calendar API
User->>KBShortcuts: press "i"
KBShortcuts->>Store: startCalendarInvite(emailId, threadId)
Store-->>Sidebar: calendarInviteRequest set → hide tab bar, force email tab
Store-->>Panel: calendarInviteRequest triggers effect → startInvite(emailId)
Panel->>IPC: calendar:extract-invite
IPC->>GCal: getCalendarList (for options + writable flag)
IPC-->>Panel: draft + calendars + requiresReauth
alt requiresReauth
Panel-->>User: Show re-auth prompt
User->>Panel: click Re-authenticate
Panel->>IPC: calendar:get-invite-options (after reauth)
IPC-->>Panel: updated calendars
end
User->>Panel: review and edit draft fields
User->>Panel: click Create and send
Panel->>IPC: calendar:create-invite
IPC->>GCal: events.insert (with conferenceDataVersion if Meet)
GCal-->>IPC: created event
IPC->>IPC: saveCalendarEvents + broadcastCalendarUpdated
IPC-->>Panel: success
Panel->>Store: clearCalendarInviteRequest()
Store-->>Sidebar: unlock tab bar
%%{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
participant User
participant KBShortcuts as useKeyboardShortcuts
participant Store as AppStore
participant Sidebar as EmailPreviewSidebar
participant Panel as CalendarPanel
participant IPC as calendar.ipc (main)
participant GCal as Google Calendar API
User->>KBShortcuts: press "i"
KBShortcuts->>Store: startCalendarInvite(emailId, threadId)
Store-->>Sidebar: calendarInviteRequest set → hide tab bar, force email tab
Store-->>Panel: calendarInviteRequest triggers effect → startInvite(emailId)
Panel->>IPC: calendar:extract-invite
IPC->>GCal: getCalendarList (for options + writable flag)
IPC-->>Panel: draft + calendars + requiresReauth
alt requiresReauth
Panel-->>User: Show re-auth prompt
User->>Panel: click Re-authenticate
Panel->>IPC: calendar:get-invite-options (after reauth)
IPC-->>Panel: updated calendars
end
User->>Panel: review and edit draft fields
User->>Panel: click Create and send
Panel->>IPC: calendar:create-invite
IPC->>GCal: events.insert (with conferenceDataVersion if Meet)
GCal-->>IPC: created event
IPC->>IPC: saveCalendarEvents + broadcastCalendarUpdated
IPC-->>Panel: success
Panel->>Store: clearCalendarInviteRequest()
Store-->>Sidebar: unlock tab bar
Reviews (13): Last reviewed commit: "Merge branch 'main' into codex/calendar-..." | 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 |
Fixes surfaced by the PR review, focused on the invite editor being modal in appearance but not in the input/state model: - Make the invite a real keyboard mode. Escape is now handled once, centrally, in useKeyboardShortcuts (clearing the request) instead of a second window listener in CalendarPanel that double-fired and also deselected the thread. The new "invite" mode suppresses every single-key thread shortcut (s/e/#/j/k/b, and <select> type-ahead) while the editor owns the sidebar, and the central Escape is an always-mounted exit so the sidebar can't soft-lock if no CalendarPanel is mounted to consume the request. - Slave the editor's open state to calendarInviteRequest: clearing the request (Escape, stale-thread safety net, or a completed create) tears the editor down, so it can no longer linger showing a stale draft after the user navigates to another thread. - Editing only the start time now shifts the end to preserve the meeting duration (Google-Calendar behavior) via a shared, unit-tested updateInviteStartTime helper, instead of leaving the end at/before the new start (which blanked the preview and blocked creation). - wallClockToInstant uses a two-pass DST fixed-point correction so bare times near a DST transition resolve to the correct instant; this feeds the sent event via normalizeToCalendarWallClock, not just the preview. Docstring corrected accordingly. - getCalendarList derives write scope from the OAuth client it already holds instead of re-opening it (which re-read credentials/token files from disk). - Remove a duplicated error-set block and a redundant setSelectedDate in startInvite. Adds unit tests for the start-time duration behavior and the DST correction.
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