@agent-native/core 0.159.2 → 0.159.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/clips/.agents/skills/meetings/SKILL.md +11 -6
  3. package/corpus/templates/clips/AGENTS.md +1 -0
  4. package/corpus/templates/clips/actions/lib/meeting-content.ts +15 -4
  5. package/corpus/templates/clips/actions/list-meetings.ts +179 -42
  6. package/corpus/templates/clips/actions/search-meetings.ts +277 -0
  7. package/corpus/templates/clips/app/components/meetings/agenda-card.tsx +273 -0
  8. package/corpus/templates/clips/app/components/meetings/day-grouped-card.tsx +113 -0
  9. package/corpus/templates/clips/app/components/meetings/meeting-history-row.tsx +117 -0
  10. package/corpus/templates/clips/app/hooks/use-navigation-state.ts +12 -2
  11. package/corpus/templates/clips/app/i18n/en-US.ts +7 -2
  12. package/corpus/templates/clips/app/routes/_app.meetings._index.tsx +263 -357
  13. package/corpus/templates/clips/changelog/2026-08-14-meetings-history-is-searchable-again.md +6 -0
  14. package/corpus/templates/clips/desktop/design-refs/granola-ux.md +17 -0
  15. package/corpus/templates/content/actions/_database-source-utils.ts +29 -2
  16. package/corpus/templates/content/app/components/editor/SlashCommandMenu.tsx +11 -11
  17. package/corpus/templates/content/app/components/editor/VisualEditor.tsx +11 -50
  18. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +57 -20
  19. package/corpus/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx +30 -0
  20. package/corpus/templates/content/app/components/editor/extensions/NotionExtensions.tsx +204 -51
  21. package/corpus/templates/content/app/global.css +4 -1
  22. package/corpus/templates/content/app/i18n-data.ts +30 -0
  23. package/corpus/templates/content/changelog/2026-08-12-toggle-blocks-now-follow-notion-style-enter-and-shift-tab-be.md +6 -0
  24. package/corpus/templates/content/docs/solutions/2026-08-12-toggle-summary-focus-persistence-shape.md +733 -0
  25. package/corpus/templates/content/shared/builder-mdx.ts +44 -4
  26. package/corpus/templates/slides/actions/get-layout-overflows.ts +65 -2
  27. package/dist/client/chat-first/app-pane.js +1 -1
  28. package/dist/client/chat-first/copy.js +1 -0
  29. package/dist/collab/awareness.d.ts +2 -2
  30. package/dist/collab/struct-routes.d.ts +1 -1
  31. package/dist/deploy/build.d.ts +6 -4
  32. package/dist/deploy/build.js +27 -11
  33. package/dist/feature-flags/index.d.ts +0 -2
  34. package/dist/feature-flags/index.js +3 -2
  35. package/dist/feature-flags/server.d.ts +2 -0
  36. package/dist/feature-flags/server.js +2 -0
  37. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  38. package/dist/index.d.ts +2 -1
  39. package/dist/index.js +2 -1
  40. package/dist/mcp/screen-memory-stdio.d.ts +7 -7
  41. package/dist/notifications/routes.d.ts +6 -6
  42. package/dist/observability/routes.d.ts +5 -5
  43. package/dist/progress/routes.d.ts +1 -1
  44. package/dist/provider-api/actions/custom-provider-registration.d.ts +2 -2
  45. package/dist/secrets/routes.d.ts +9 -9
  46. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  47. package/dist/server/auth.js +10 -0
  48. package/dist/server/index.d.ts +1 -1
  49. package/dist/server/index.js +1 -1
  50. package/dist/server/onboarding-html.js +16 -257
  51. package/dist/shared/auth-copy.d.ts +34 -0
  52. package/dist/shared/auth-copy.js +293 -0
  53. package/dist/shared/index.d.ts +1 -0
  54. package/dist/shared/index.js +1 -0
  55. package/docs/content/actions-advanced.mdx +1 -1
  56. package/docs/content/portal.mdx +21 -8
  57. package/package.json +4 -2
  58. package/corpus/templates/clips/app/components/meetings/meeting-card.tsx +0 -333
package/corpus/README.md CHANGED
@@ -31,4 +31,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
31
31
 
32
32
  ## Generated Counts
33
33
 
34
- - template files: 8257
34
+ - template files: 8263
@@ -25,9 +25,9 @@ For press-and-hold dictations (Hold-Fn / Cmd+Shift+Space) and the `/dictate` tab
25
25
 
26
26
  ## Design reference
27
27
 
28
- The Meetings tab intentionally mirrors **Granola**: card grid grouped by day, two-pane detail (transcript left + AI notes right), inline title edit, "Generate notes" button, per-attendee action items. See `templates/clips/desktop/design-refs/granola-ux.md` for the source-of-truth interaction notes — read this before redesigning any Meetings surface. (Wispr-style press-and-hold patterns belong in the Dictate skill, not here.)
28
+ The Meetings tab intentionally mirrors **Granola**: a compact "Coming up" card over a dense reverse-chronological history, two-pane detail (transcript left + AI notes right), inline title edit, "Generate notes" button, per-attendee action items. See `templates/clips/desktop/design-refs/granola-ux.md` for the source-of-truth interaction notes — read this before redesigning any Meetings surface. (Wispr-style press-and-hold patterns belong in the Dictate skill, not here.)
29
29
 
30
- > If `granola-ux.md` is missing, treat that as a TODO the Wispr ref has been written but the Granola ref hasn't landed yet.
30
+ The list was a grid of tiles until 2026-08-14. That contradicted §2 of the ref ("list-row style rather than tile cards") and cost so much vertical space that history sat below the fold, which is how a user ends up unable to find last month's call.
31
31
 
32
32
  ## Data model touched
33
33
 
@@ -75,7 +75,8 @@ When meetings are enabled, the Clips desktop app also watches for native Zoom (`
75
75
 
76
76
  | Action | What it does |
77
77
  | ------------------------- | --------------------------------------------------------------------- |
78
- | `list-meetings` | Upcoming + past, scoped via `accessFilter`; reads connected Google Calendar live |
78
+ | `list-meetings` | Upcoming + past, scoped via `accessFilter`; reads connected Google Calendar live. `hasContent` filters to meetings worth reopening; `offset` + `hasMore` page the history |
79
+ | `search-meetings` | Find a meeting by title, summary, notes, attendee, or linked transcript text, with a match snippet. Use this — not `list-meetings` — when the user describes what was said rather than when it happened |
79
80
  | `get-meeting` | One meeting + participants + segments + notes |
80
81
  | `create-meeting` | Create a meeting row (`source`: `calendar` / `adhoc` / `manual`); desktop adhoc Zoom/Teams detection passes `source: "adhoc"` |
81
82
  | `update-meeting` | Inline title/notes edits and owner/admin visibility changes; meeting share links include the full transcript whenever one exists |
@@ -159,20 +160,24 @@ When on `view: "dictate"`, the block instead contains a `dictation` object with
159
160
  | "Summarize the standup I just finished" | `pnpm action finalize-meeting --id=<id>` (delegates to agent for Gemini cleanup) |
160
161
  | "Create a meeting note for the call I just finished" | Prefer the current calendar event. If it was not on the calendar, send the user to `/record` instead of creating a fake meeting from the UI. |
161
162
  | "Connect my Google Calendar" | `pnpm action connect-calendar --provider=google` then open returned `authUrl` |
162
- | "Show my action items from last week" | `list-meetings --since=<iso>`, then collect `actionItemsJson` and filter by `assigneeEmail` |
163
+ | "Show my action items from last week" | `list-meetings --view=past --hasContent`, then collect `actionItemsJson` and filter by `assigneeEmail` |
163
164
 
164
165
  ## How the agent uses Meetings
165
166
 
166
167
  These flows are common enough to memorize:
167
168
 
168
- - **"Summarize my last meeting with Alice"** — `list-meetings` filtered by participant, pick the most recent, `get-meeting`, then `finalize-meeting` if `summaryMd` is empty.
169
+ - **"Summarize my last meeting with Alice"** — `search-meetings --query=alice`, pick the most recent, `get-meeting`, then `finalize-meeting` if `summaryMd` is empty.
170
+ - **"Find the call where we discussed the renewal"** — `search-meetings --query="renewal"`. It reads transcripts, so it finds calls the user never titled usefully; `list-meetings` cannot answer this.
169
171
  - **"Show me action items I owe Bob"** — `list-meetings` (recent), aggregate `actionItemsJson`, filter `assigneeEmail` matching Bob's email. Mention the mic+system caveat if the user expects coverage of remote attendees.
170
172
  - **"Create a meeting note for the call I just finished"** — prefer an existing calendar-synced meeting. If the call was not on the calendar, send the user to `/record`; do not invent a fake calendar meeting in the visible Meetings list.
171
173
  - **"What did Alice commit to in last Tuesday's standup?"** — `get-meeting`, scan `actionItemsJson` filtered by assignee, fall back to grepping the transcript segments tagged `source: "system"` (since Alice is remote).
172
174
 
173
175
  ## UI conventions (don't break)
174
176
 
175
- - **Card grid** for meeting lists, grouped by day with a date header (Today / Tomorrow / Weekday Date).
177
+ - **Upcoming is one compact card** ("Coming up"), never a tile grid: a date column per day, one line per event, and Join / Open notes only on the live or imminent row. It must not push history below the fold.
178
+ - **History is dense one-line rows**, grouped by day with a date header (Today / Yesterday / Weekday Date): attendee avatars, title, attendee names, right-aligned time. No summary preview and no per-row status pills — transcript/notes state belongs on the detail page, not repeated down a list.
179
+ - **History is paged, never capped.** The list reads `list-meetings` with `hasContent: true` (not `recordedOnly`) so desktop live notes without a linked recording still appear, and pages with `offset` + `hasMore` behind a "Load older" button.
180
+ - **The search box is server-side**, calling `search-meetings`. Never filter the loaded page client-side: the meeting a user is hunting for is usually one they have not scrolled to.
176
181
  - **Calendar-sourced list**: no "New meeting" CTA and no manual sync requirement in the Meetings list. Users connect/reconnect/disconnect the calendar from the calendar settings menu; events are fetched live from Google Calendar.
177
182
  - **Two-pane detail**: transcript (left) + AI notes (right) with a "Generate notes" button in the header.
178
183
  - **Live indicator** is a red animated dot — never a sparkle or a robot icon.
@@ -88,6 +88,7 @@ video sharing app. The agent and the UI share the same SQL data and actions.
88
88
  | `regenerate-title`, `-summary`, `-chapters` | AI metadata |
89
89
  | `trim-`, `split-recording`, `remove-silences`, `remove-filler-words` | Edits |
90
90
  | `list-meetings`, `get-`, `update-`, `finalize-meeting` | Meetings |
91
+ | `search-meetings` | Find a meeting by title, summary, notes, attendee, or transcript |
91
92
  | `list-dictations`, `cleanup-dictation` | Dictation history |
92
93
  | `add-comment`, `update-comment`, `create-folder`, `create-space` | Comments, folders |
93
94
  | `share-resource`, `set-resource-visibility`, `build-embed-url` | Share, embed |
@@ -1,9 +1,20 @@
1
1
  /**
2
- * Whether a persisted meeting contains something a user can reopen.
2
+ * What makes a meeting worth showing in history.
3
3
  *
4
- * Calendar events are materialized before they are recorded, so the meetings
5
- * table also contains empty calendar rows. History should keep notes and
6
- * completed meeting rows even when no recording was linked.
4
+ * A calendar-sourced `clips_meetings` row is created as soon as the user
5
+ * records or edits an event, so the table also holds bare husks that carry
6
+ * nothing a user would ever reopen. Two places need that distinction and they
7
+ * MUST agree:
8
+ *
9
+ * 1. this predicate, for rows already in memory, and
10
+ * 2. `meetingHasContentFilter()` in `../list-meetings.ts`, its SQL mirror.
11
+ *
12
+ * The same rule decides which rows the history list returns AND which
13
+ * persisted rows a live calendar event may supersede. Let the two drift and
14
+ * history rows start vanishing behind their own calendar events — which is the
15
+ * bug that made `recordedOnly` look like a reasonable filter in the first
16
+ * place. `recordingId` alone is NOT the rule: desktop live notes produce a
17
+ * meeting with a summary and no linked recording.
7
18
  */
8
19
  export interface MeetingContentFields {
9
20
  recordingId?: string | null;
@@ -2,11 +2,22 @@
2
2
  * List meetings visible to the current user.
3
3
  *
4
4
  * Filtering:
5
- * - view='upcoming' — scheduled_start in the future, not trashed
6
- * - view='past' actual_end OR scheduled_end in the past, not trashed
5
+ * - view='upcoming' — scheduled_start in the future and not yet started
6
+ * - view='agenda' scheduled_start from `agendaLookbackMin` ago onward,
7
+ * started or not. The Meetings tab's rolling day view.
8
+ * - view='past' — actual_end OR scheduled_end in the past, OR a
9
+ * manual/ad-hoc meeting with no scheduling and no
10
+ * in-progress recording; not trashed
7
11
  * - view='all' — every visible meeting (excluding trashed)
8
12
  * - view='trash' — trashed_at is not null
9
13
  *
14
+ * 'upcoming' and 'agenda' differ on purpose: desktop reminders need "has not
15
+ * started yet", the Meetings agenda needs "belongs to the day you are in".
16
+ *
17
+ * `hasContent` narrows any view to meetings that actually hold something —
18
+ * see `./lib/meeting-content.ts`. The Meetings history list uses it so notes
19
+ * taken without a linked recording still appear.
20
+ *
10
21
  * Calendar behavior:
11
22
  * Connected Google Calendar accounts are read live on every call. We only
12
23
  * materialize a calendar event into `clips_meetings` when the user records
@@ -21,6 +32,7 @@ import {
21
32
  asc,
22
33
  desc,
23
34
  eq,
35
+ inArray,
24
36
  isNull,
25
37
  isNotNull,
26
38
  lt,
@@ -52,8 +64,14 @@ import { booleanParam } from "./lib/cli-params.js";
52
64
  import { meetingRowHasContent } from "./lib/meeting-content.js";
53
65
 
54
66
  const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
67
+ // Must stay above `limit`'s own max (500): the persisted query always fetches
68
+ // one row past the caller's limit as a `hasMore` probe, so if this cap ever
69
+ // equalled 500 a caller requesting the max limit would have that probe row
70
+ // truncated away and `hasMore` would silently read false forever at that
71
+ // exact boundary, even with more rows still to page through.
72
+ const MAX_PERSISTED_ROWS_PER_QUERY = 1000;
55
73
 
56
- /** SQL mirror of `meetingRowHasContent`. Keep history filtering consistent. */
74
+ /** SQL mirror of `meetingRowHasContent`. Keep the two in lockstep. */
57
75
  function meetingHasContentFilter() {
58
76
  return or(
59
77
  isNotNull(schema.meetings.recordingId),
@@ -71,9 +89,20 @@ export default defineAction({
71
89
  "List meetings (Granola-style) the current user has access to. Connected calendars are read live; use view='upcoming' / 'past' / 'all' / 'trash' to filter by lifecycle.",
72
90
  schema: z.object({
73
91
  view: z
74
- .enum(["upcoming", "past", "all", "trash"])
92
+ .enum(["upcoming", "agenda", "past", "all", "trash"])
75
93
  .default("upcoming")
76
- .describe("Which list to show"),
94
+ .describe(
95
+ "Which list to show. 'agenda' is the Meetings tab's rolling window — everything scheduled from `agendaLookbackMin` ago onward, so calls that already happened today stay on the agenda; 'upcoming' is strictly not-yet-started and is what desktop reminders poll.",
96
+ ),
97
+ agendaLookbackMin: z.coerce
98
+ .number()
99
+ .int()
100
+ .min(0)
101
+ .max(60 * 24 * 7)
102
+ .default(60 * 24)
103
+ .describe(
104
+ "How far back view='agenda' reaches, in minutes. Default 1440 (24h): a meeting from earlier today is still part of today.",
105
+ ),
77
106
  limit: z.coerce.number().int().min(1).max(500).default(100),
78
107
  offset: z.coerce.number().int().min(0).default(0),
79
108
  recordedOnly: booleanParam
@@ -82,7 +111,7 @@ export default defineAction({
82
111
  hasContent: booleanParam
83
112
  .default(false)
84
113
  .describe(
85
- "Only return persisted meetings that contain a recording, completed capture, notes, a summary, bullets, or action items.",
114
+ "Only return persisted meetings that hold something worth reopening — a linked recording, an actual start/end, notes, a summary, bullets, or action items. Prefer this over recordedOnly for history: live notes taken without a linked recording still count.",
86
115
  ),
87
116
  includeLiveCalendar: booleanParam
88
117
  .default(true)
@@ -129,9 +158,11 @@ export default defineAction({
129
158
  // slice(offset, offset + limit) at the end. To make that final slice
130
159
  // correct we must fetch enough rows from BOTH sources to cover the whole
131
160
  // offset + limit window before merging — fetching only `limit` would drop
132
- // events once offset > 0 or the calendar is large. Keep the live-event cap
133
- // so a huge calendar can't blow up the request. Persisted history needs
134
- // the full sentinel window so pages remain available after 500 rows.
161
+ // events once offset > 0 or the calendar is large. Keep the hard caps
162
+ // (MAX_PERSISTED_ROWS_PER_QUERY persisted, 250 live) so a huge calendar
163
+ // can't blow up the request. We fetch one row past the window purely as a
164
+ // `hasMore` probe: the extra row is never returned, it only tells the
165
+ // caller another page exists.
135
166
  const windowCount = args.offset + args.limit;
136
167
  const upcomingWindowMaxIso = args.upcomingWithinMin
137
168
  ? new Date(
@@ -143,6 +174,28 @@ export default defineAction({
143
174
  startedWithinMin > 0
144
175
  ? new Date(now.getTime() - startedWithinMin * 60 * 1000).toISOString()
145
176
  : nowIso;
177
+ const agendaFloorIso = new Date(
178
+ now.getTime() - args.agendaLookbackMin * 60 * 1000,
179
+ ).toISOString();
180
+ // Mirrors the live calendar's own forward cap (see timeMax below) so a
181
+ // meeting scheduled months out can't sit ahead of nearer ones in a
182
+ // consistent window; harmless under the ascending sort today, but keeps
183
+ // "agenda" honestly meaning "the near future" if that ever changes.
184
+ const agendaCeilingIso = new Date(
185
+ now.getTime() + THIRTY_DAYS_MS,
186
+ ).toISOString();
187
+ // 'agenda' and 'upcoming' both read forward in time, so they share the
188
+ // ascending sort. They differ in where the window starts and in whether a
189
+ // meeting that already started is still allowed in.
190
+ const isForwardLooking = args.view === "upcoming" || args.view === "agenda";
191
+ // Live calendar events require a global re-sort against persisted rows, so
192
+ // that branch keeps the "fetch the whole window from offset 0" approach.
193
+ // Every other view can paginate for real in SQL — which matters here
194
+ // because the whole-window approach caps out at 500 rows no matter how
195
+ // large `offset` grows, silently stranding "Load older" once history
196
+ // passes 500 meetings.
197
+ const willMergeLiveCalendar =
198
+ args.includeLiveCalendar && !args.recordedOnly && args.view !== "trash";
146
199
 
147
200
  const whereClauses = [accessFilter(schema.meetings, schema.meetingShares)];
148
201
 
@@ -166,14 +219,41 @@ export default defineAction({
166
219
  : undefined,
167
220
  )!,
168
221
  );
222
+ } else if (args.view === "agenda") {
223
+ // Everything scheduled from the lookback floor onward. Deliberately does
224
+ // NOT exclude meetings that already started or ended: "the call you just
225
+ // finished" is the most useful row on a day's agenda, and excluding it is
226
+ // what made the old upcoming-only list feel like it had lost your day.
227
+ whereClauses.push(
228
+ and(
229
+ isNotNull(schema.meetings.scheduledStart),
230
+ gte(schema.meetings.scheduledStart, agendaFloorIso),
231
+ lte(schema.meetings.scheduledStart, agendaCeilingIso),
232
+ )!,
233
+ );
169
234
  } else if (args.view === "past") {
170
- // Either completed (actualEnd set) or scheduled-end in the past.
235
+ // Either completed (actualEnd set), scheduled-end in the past, or a
236
+ // manual/ad-hoc meeting with no scheduling and no in-progress recording
237
+ // at all. That last group (dictation-style notes with no calendar event
238
+ // and no actualStart) can never satisfy 'agenda' or 'upcoming' — both
239
+ // require scheduledStart — so 'past' is the only lifecycle view left
240
+ // for their content to surface in. A meeting that IS actively recording
241
+ // (actualStart set, actualEnd not yet) still waits for actualEnd.
171
242
  whereClauses.push(
172
243
  or(
173
244
  isNotNull(schema.meetings.actualEnd),
174
245
  and(
175
246
  isNotNull(schema.meetings.scheduledEnd),
176
247
  lt(schema.meetings.scheduledEnd, nowIso),
248
+ // A meeting that started recording and is still going (actualStart
249
+ // set, actualEnd not yet) waits for actualEnd even if its schedule
250
+ // says it should be over — otherwise it double-appears here and on
251
+ // the Agenda while still in progress.
252
+ isNull(schema.meetings.actualStart),
253
+ )!,
254
+ and(
255
+ isNull(schema.meetings.scheduledStart),
256
+ isNull(schema.meetings.actualStart),
177
257
  )!,
178
258
  )!,
179
259
  );
@@ -185,22 +265,55 @@ export default defineAction({
185
265
  whereClauses.push(meetingHasContentFilter());
186
266
  }
187
267
 
188
- const orderBy =
189
- args.view === "upcoming"
190
- ? [asc(schema.meetings.scheduledStart)]
191
- : [
192
- desc(
193
- sql`COALESCE(${schema.meetings.actualStart}, ${schema.meetings.scheduledStart}, ${schema.meetings.createdAt})`,
194
- ),
195
- ];
196
-
197
- const rows = await db
198
- .select()
199
- .from(schema.meetings)
200
- .where(and(...whereClauses))
201
- .orderBy(...orderBy)
202
- .limit(windowCount + 1)
203
- .offset(0);
268
+ const orderBy = isForwardLooking
269
+ ? [asc(schema.meetings.scheduledStart)]
270
+ : [
271
+ desc(
272
+ sql`COALESCE(${schema.meetings.actualStart}, ${schema.meetings.scheduledStart}, ${schema.meetings.createdAt})`,
273
+ ),
274
+ ];
275
+
276
+ // `persistedHasMore` is only meaningful (and only trusted below) on the
277
+ // no-merge path the merge path derives its own `hasMore` from the
278
+ // combined, re-sorted array once live events are folded in.
279
+ let persistedHasMore = false;
280
+ let rows: Array<typeof schema.meetings.$inferSelect>;
281
+ if (willMergeLiveCalendar) {
282
+ rows = await db
283
+ .select()
284
+ .from(schema.meetings)
285
+ .where(and(...whereClauses))
286
+ .orderBy(...orderBy)
287
+ .limit(Math.min(MAX_PERSISTED_ROWS_PER_QUERY, windowCount + 1))
288
+ .offset(0);
289
+ } else {
290
+ const page = await db
291
+ .select()
292
+ .from(schema.meetings)
293
+ .where(and(...whereClauses))
294
+ .orderBy(...orderBy)
295
+ .limit(Math.min(MAX_PERSISTED_ROWS_PER_QUERY, args.limit + 1))
296
+ .offset(args.offset);
297
+ persistedHasMore = page.length > args.limit;
298
+ rows = page.slice(0, args.limit);
299
+ }
300
+
301
+ // Participants drive the history row's avatar stack and "who was on this
302
+ // call" subtitle. Live calendar events carry their own attendees, so this
303
+ // only backfills persisted rows, in one batched read rather than per row.
304
+ const persistedIds = rows.map((m) => m.id);
305
+ const participantRows = persistedIds.length
306
+ ? await db
307
+ .select()
308
+ .from(schema.meetingParticipants)
309
+ .where(inArray(schema.meetingParticipants.meetingId, persistedIds))
310
+ : [];
311
+ const participantsByMeeting = new Map<string, typeof participantRows>();
312
+ for (const participant of participantRows) {
313
+ const list = participantsByMeeting.get(participant.meetingId) ?? [];
314
+ list.push(participant);
315
+ participantsByMeeting.set(participant.meetingId, list);
316
+ }
204
317
 
205
318
  // Add a derived `summaryPreview` (first ~100 chars of summaryMd) so the
206
319
  // Granola-style cards can render a one-liner without re-parsing markdown.
@@ -209,7 +322,11 @@ export default defineAction({
209
322
  const preview = summary
210
323
  ? summary.replace(/\s+/g, " ").slice(0, 100)
211
324
  : null;
212
- return { ...m, summaryPreview: preview };
325
+ return {
326
+ ...m,
327
+ summaryPreview: preview,
328
+ participants: participantsByMeeting.get(m.id) ?? [],
329
+ };
213
330
  });
214
331
 
215
332
  const liveMeetings: any[] = [];
@@ -231,11 +348,7 @@ export default defineAction({
231
348
  // Google event externalId so we can match it against the emitted set.
232
349
  const calendarEventIdToExternalId = new Map<string, string>();
233
350
 
234
- if (
235
- args.includeLiveCalendar &&
236
- !args.recordedOnly &&
237
- args.view !== "trash"
238
- ) {
351
+ if (willMergeLiveCalendar) {
239
352
  const accountWhere = [
240
353
  accessFilter(schema.calendarAccounts, schema.calendarAccountShares),
241
354
  eq(schema.calendarAccounts.status, "connected"),
@@ -269,10 +382,12 @@ export default defineAction({
269
382
  ? new Date(now.getTime() - THIRTY_DAYS_MS).toISOString()
270
383
  : args.view === "all"
271
384
  ? new Date(now.getTime() - THIRTY_DAYS_MS).toISOString()
272
- : startedWithinMin > 0
273
- ? upcomingWindowMinIso
274
- : // Small cushion for clock skew when listing pure upcoming.
275
- new Date(now.getTime() - 60 * 1000).toISOString();
385
+ : args.view === "agenda"
386
+ ? agendaFloorIso
387
+ : startedWithinMin > 0
388
+ ? upcomingWindowMinIso
389
+ : // Small cushion for clock skew when listing pure upcoming.
390
+ new Date(now.getTime() - 60 * 1000).toISOString();
276
391
  const timeMax =
277
392
  args.view === "past"
278
393
  ? nowIso
@@ -348,6 +463,14 @@ export default defineAction({
348
463
  continue;
349
464
  }
350
465
  if (args.view === "past" && endMs >= now.getTime()) continue;
466
+ // The agenda keeps already-finished events, but only back to its
467
+ // floor — anything that started before it belongs in Past.
468
+ if (
469
+ args.view === "agenda" &&
470
+ startMs < Date.parse(agendaFloorIso)
471
+ ) {
472
+ continue;
473
+ }
351
474
  if (
352
475
  upcomingWindowMaxIso &&
353
476
  startMs > Date.parse(upcomingWindowMaxIso)
@@ -364,11 +487,16 @@ export default defineAction({
364
487
  meeting: persisted,
365
488
  });
366
489
  if (liveMeeting) {
367
- liveMeetings.push(liveMeeting);
490
+ // Mark the event as emitted regardless of hasContent, so an
491
+ // empty correlated persisted husk (below) stays suppressed
492
+ // rather than reappearing once its live event is filtered out.
368
493
  emittedLiveEventKeys.add(liveMeeting.id);
369
494
  if (liveMeeting.calendarExternalId) {
370
495
  emittedLiveEventKeys.add(liveMeeting.calendarExternalId);
371
496
  }
497
+ if (!args.hasContent || meetingRowHasContent(liveMeeting)) {
498
+ liveMeetings.push(liveMeeting);
499
+ }
372
500
  }
373
501
  }
374
502
 
@@ -415,6 +543,18 @@ export default defineAction({
415
543
  combined.push(meeting);
416
544
  }
417
545
 
546
+ // Without a live merge, `combined` is exactly `persistedMeetings` — a page
547
+ // the DB already ordered and offset correctly. Re-sorting by a different
548
+ // key and re-slicing by `offset` here would both scramble that order and
549
+ // apply the offset a second time, so the no-merge path returns as-is.
550
+ if (!willMergeLiveCalendar) {
551
+ return {
552
+ meetings: combined,
553
+ calendarErrors,
554
+ hasMore: persistedHasMore,
555
+ };
556
+ }
557
+
418
558
  combined.sort((a, b) => {
419
559
  const aStart = Date.parse(a.scheduledStart ?? a.createdAt ?? "");
420
560
  const bStart = Date.parse(b.scheduledStart ?? b.createdAt ?? "");
@@ -424,11 +564,8 @@ export default defineAction({
424
564
  });
425
565
 
426
566
  const meetings = combined.slice(args.offset, args.offset + args.limit);
567
+ const hasMore = combined.length > args.offset + args.limit;
427
568
 
428
- return {
429
- meetings,
430
- calendarErrors,
431
- hasMore: combined.length > args.offset + args.limit,
432
- };
569
+ return { meetings, calendarErrors, hasMore };
433
570
  },
434
571
  });