@agent-native/core 0.159.2 → 0.159.3

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 (34) 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/deploy/build.d.ts +6 -4
  28. package/dist/deploy/build.js +27 -11
  29. package/dist/mcp/screen-memory-stdio.d.ts +7 -7
  30. package/dist/notifications/routes.d.ts +3 -3
  31. package/dist/observability/routes.d.ts +2 -2
  32. package/dist/secrets/routes.d.ts +6 -6
  33. package/package.json +2 -2
  34. package/corpus/templates/clips/app/components/meetings/meeting-card.tsx +0 -333
@@ -5,11 +5,9 @@ import {
5
5
  IconAlertTriangle,
6
6
  IconBellRinging,
7
7
  IconCalendar,
8
- IconCheck,
9
8
  IconExternalLink,
10
9
  IconLoader2,
11
10
  IconMicrophone2,
12
- IconNotes,
13
11
  IconPlugConnected,
14
12
  IconPlugOff,
15
13
  IconPlus,
@@ -19,16 +17,23 @@ import {
19
17
  } from "@tabler/icons-react";
20
18
  import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
21
19
  import { useCallback, useEffect, useMemo, useState } from "react";
22
- import { NavLink, useSearchParams } from "react-router";
20
+ import { useSearchParams } from "react-router";
23
21
  import { toast } from "sonner";
24
22
 
25
23
  import { PageHeader } from "@/components/library/page-header";
24
+ import {
25
+ AgendaCard,
26
+ AgendaCardSkeleton,
27
+ } from "@/components/meetings/agenda-card";
26
28
  import type { AttendeeStackParticipant } from "@/components/meetings/attendee-stack";
27
- import { DayHeader, formatDayLabel } from "@/components/meetings/day-header";
28
29
  import {
29
- UpcomingMeetingCard,
30
- MeetingCardSkeleton,
31
- } from "@/components/meetings/meeting-card";
30
+ DayGroupedCard,
31
+ groupByCalendarDay,
32
+ } from "@/components/meetings/day-grouped-card";
33
+ import {
34
+ MeetingHistoryRow,
35
+ MeetingHistoryRowSkeleton,
36
+ } from "@/components/meetings/meeting-history-row";
32
37
  import {
33
38
  AlertDialog,
34
39
  AlertDialogAction,
@@ -49,6 +54,7 @@ import {
49
54
  DropdownMenuTrigger,
50
55
  } from "@/components/ui/dropdown-menu";
51
56
  import { Input } from "@/components/ui/input";
57
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
52
58
  import enMessages from "@/i18n/en-US";
53
59
  import {
54
60
  buildMeetingHistoryQuery,
@@ -59,6 +65,12 @@ export function meta() {
59
65
  return [{ title: enMessages.meetingsRoute.pageTitle }];
60
66
  }
61
67
 
68
+ type MeetingsTab = "agenda" | "past";
69
+
70
+ function isMeetingsTab(value: string | null): value is MeetingsTab {
71
+ return value === "agenda" || value === "past";
72
+ }
73
+
62
74
  interface Meeting {
63
75
  id: string;
64
76
  title: string;
@@ -66,6 +78,7 @@ interface Meeting {
66
78
  scheduledEnd?: string | null;
67
79
  actualStart?: string | null;
68
80
  actualEnd?: string | null;
81
+ createdAt?: string | null;
69
82
  recordingId?: string | null;
70
83
  joinUrl?: string | null;
71
84
  platform?: string | null;
@@ -83,6 +96,11 @@ interface Meeting {
83
96
  participants?: AttendeeStackParticipant[];
84
97
  }
85
98
 
99
+ interface SearchMeetingResult extends Meeting {
100
+ snippet?: string | null;
101
+ matchType?: string;
102
+ }
103
+
86
104
  interface CalendarFetchError {
87
105
  accountId: string;
88
106
  error: string;
@@ -187,166 +205,47 @@ function calendarAccountLabel(account: CalendarAccount): string {
187
205
  );
188
206
  }
189
207
 
190
- function groupByDay(meetings: Meeting[]): Array<[string, Meeting[]]> {
191
- const groups = new Map<string, Meeting[]>();
192
- for (const m of meetings) {
193
- const key = formatDayLabel(m.scheduledStart);
194
- const arr = groups.get(key) ?? [];
195
- arr.push(m);
196
- groups.set(key, arr);
197
- }
198
- for (const arr of groups.values()) {
199
- arr.sort(
200
- (a, b) =>
201
- new Date(b.scheduledStart).getTime() -
202
- new Date(a.scheduledStart).getTime(),
203
- );
204
- }
205
- return Array.from(groups.entries());
206
- }
207
-
208
- function formatTime(iso?: string | null): string {
209
- if (!iso) return "";
210
- try {
211
- return new Date(iso).toLocaleTimeString([], {
212
- hour: "numeric",
213
- minute: "2-digit",
214
- });
215
- } catch {
216
- return "";
217
- }
208
+ // Manual/ad-hoc notes-only meetings admitted into the past view (see
209
+ // list-meetings' view='past' predicate) can have neither actualStart nor
210
+ // scheduledStart createdAt is the only timestamp left to group and display
211
+ // them by.
212
+ function historyIso(m: Meeting): string {
213
+ return m.actualStart ?? m.scheduledStart ?? m.createdAt ?? "";
218
214
  }
219
215
 
220
- function formatTimeRange(meeting: Meeting): string {
221
- const start = formatTime(meeting.actualStart ?? meeting.scheduledStart);
222
- const end = formatTime(meeting.actualEnd ?? meeting.scheduledEnd);
223
- if (start && end) return `${start} - ${end}`;
224
- return start || end || "Recorded meeting";
216
+ function historyTimestampMs(m: Meeting): number {
217
+ const ms = Date.parse(historyIso(m));
218
+ return Number.isNaN(ms) ? 0 : ms;
225
219
  }
226
220
 
227
- function buildPreview(meeting: Meeting): string | null {
228
- const raw = meeting.summaryPreview || meeting.summaryMd || "";
229
- const plain = raw
230
- .replace(/[#*`>_~-]+/g, " ")
231
- .replace(/\s+/g, " ")
232
- .trim();
233
- return plain ? plain.slice(0, 180) : null;
234
- }
235
-
236
- function hasGeneratedNotes(meeting: Meeting): boolean {
237
- return Boolean(meeting.summaryMd?.trim() || meeting.userNotesMd?.trim());
238
- }
239
-
240
- function RecordedMeetingRow({ meeting }: { meeting: Meeting }) {
241
- const t = useT();
242
- const preview = buildPreview(meeting);
243
- const transcriptReady = meeting.transcriptStatus === "ready";
244
- const notesReady = hasGeneratedNotes(meeting);
245
-
246
- return (
247
- <NavLink
248
- to={`/meetings/${meeting.id}`}
249
- className="group flex items-start gap-4 rounded-md border border-border/70 bg-background px-4 py-3 transition-colors hover:border-foreground/20 hover:bg-accent/20 focus:outline-none focus:ring-2 focus:ring-ring"
250
- >
251
- <div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-border bg-accent/30 text-muted-foreground">
252
- <IconCalendar className="h-4 w-4" />
253
- </div>
254
- <div className="min-w-0 flex-1">
255
- <div className="flex min-w-0 flex-wrap items-center gap-2">
256
- <h3 className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
257
- {meeting.title || "Untitled meeting"}
258
- </h3>
259
- <span className="shrink-0 text-xs tabular-nums text-muted-foreground">
260
- {formatTimeRange(meeting)}
261
- </span>
262
- </div>
263
- <p className="mt-1 line-clamp-2 text-xs leading-relaxed text-muted-foreground">
264
- {preview || "No summary yet"}
265
- </p>
266
- <div className="mt-2 flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
267
- {transcriptReady ? (
268
- <span className="inline-flex items-center gap-1 rounded border border-emerald-500/20 bg-emerald-500/10 px-1.5 py-0.5 text-emerald-700 dark:text-emerald-300">
269
- <IconCheck className="h-3 w-3" />
270
- Transcript
271
- </span>
272
- ) : (
273
- <span className="rounded border border-border px-1.5 py-0.5">
274
- {t("meetingsRoute.transcriptPending")}
275
- </span>
276
- )}
277
- {notesReady ? (
278
- <span className="inline-flex items-center gap-1 rounded border border-amber-500/20 bg-amber-500/10 px-1.5 py-0.5 text-amber-700 dark:text-amber-300">
279
- <IconNotes className="h-3 w-3" />
280
- Notes
281
- </span>
282
- ) : (
283
- <span className="rounded border border-border px-1.5 py-0.5">
284
- {t("meetingsRoute.notesPending")}
285
- </span>
286
- )}
287
- </div>
288
- </div>
289
- </NavLink>
290
- );
291
- }
292
-
293
- function RecordedMeetingsList({ meetings }: { meetings: Meeting[] }) {
294
- const t = useT();
295
- if (meetings.length === 0) return null;
296
- const groups = groupByDay(meetings);
297
- return (
298
- <section className="space-y-4">
299
- <h2 className="text-xs font-semibold uppercase tracking-[0.08em] text-muted-foreground/80 px-1">
300
- {t("meetingsRoute.pastRecordings")}
301
- </h2>
302
- {groups.map(([day, items]) => (
303
- <div key={day} className="space-y-2">
304
- <DayHeader label={day} />
305
- <div className="space-y-2">
306
- {items.map((m) => (
307
- <RecordedMeetingRow key={m.id} meeting={m} />
308
- ))}
309
- </div>
310
- </div>
311
- ))}
312
- </section>
313
- );
314
- }
315
-
316
- function UpcomingMeetingsList({ meetings }: { meetings: Meeting[] }) {
221
+ // Per @shawnmcclelland's review on #2887: Past now shares the same
222
+ // day-column card shell as Agenda instead of a bare DayHeader label over a
223
+ // flat row list, so the two tabs read as one surface. The explicit sort
224
+ // comparator matters here too — the array can arrive sorted by a different
225
+ // field (list-meetings' merge path sorts by `scheduledStart ?? createdAt`,
226
+ // search-meetings doesn't guarantee this key either), so a meeting that
227
+ // started later than scheduled could otherwise land out of order within its day.
228
+ function MeetingHistoryList({
229
+ meetings,
230
+ snippets,
231
+ }: {
232
+ meetings: Meeting[];
233
+ snippets?: Map<string, string | null | undefined>;
234
+ }) {
317
235
  if (meetings.length === 0) return null;
318
- const groups = groupByDay(meetings);
319
- return (
320
- <section className="space-y-4">
321
- <h2 className="text-xs font-semibold uppercase tracking-[0.08em] text-muted-foreground/80 px-1">
322
- Upcoming
323
- </h2>
324
- {groups.map(([day, items]) => (
325
- <div key={day} className="space-y-2">
326
- <DayHeader label={day} />
327
- <div className="grid grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-3">
328
- {items.map((m) => (
329
- <UpcomingMeetingCard key={m.id} meeting={m} />
330
- ))}
331
- </div>
332
- </div>
333
- ))}
334
- </section>
236
+ const days = groupByCalendarDay(
237
+ meetings,
238
+ historyIso,
239
+ (a, b) => historyTimestampMs(b) - historyTimestampMs(a),
335
240
  );
336
- }
337
-
338
- function UpcomingMeetingsLoading() {
339
241
  return (
340
- <section className="space-y-4">
341
- <h2 className="text-xs font-semibold uppercase tracking-[0.08em] text-muted-foreground/80 px-1">
342
- Upcoming
343
- </h2>
344
- <div className="grid grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-3">
345
- {Array.from({ length: 3 }).map((_, i) => (
346
- <MeetingCardSkeleton key={i} />
347
- ))}
348
- </div>
349
- </section>
242
+ <DayGroupedCard
243
+ groups={days}
244
+ getIso={historyIso}
245
+ renderRow={(m) => (
246
+ <MeetingHistoryRow meeting={m} snippet={snippets?.get(m.id)} />
247
+ )}
248
+ />
350
249
  );
351
250
  }
352
251
 
@@ -371,24 +270,6 @@ function CalendarReauthBanner({ onReconnect }: { onReconnect: () => void }) {
371
270
  );
372
271
  }
373
272
 
374
- function RecordedMeetingSkeleton() {
375
- return (
376
- <div className="rounded-md border border-border/70 bg-background px-4 py-3">
377
- <div className="flex items-start gap-4">
378
- <div className="h-8 w-8 rounded-md bg-muted animate-pulse" />
379
- <div className="min-w-0 flex-1 space-y-2">
380
- <div className="flex justify-between gap-4">
381
- <div className="h-4 w-2/5 rounded bg-muted animate-pulse" />
382
- <div className="h-3 w-24 rounded bg-muted/70 animate-pulse" />
383
- </div>
384
- <div className="h-3 w-full rounded bg-muted/70 animate-pulse" />
385
- <div className="h-3 w-4/5 rounded bg-muted/70 animate-pulse" />
386
- </div>
387
- </div>
388
- </div>
389
- );
390
- }
391
-
392
273
  function CalendarConnectionAction({
393
274
  label,
394
275
  onConnected,
@@ -440,51 +321,23 @@ function MeetingNotesSteps() {
440
321
  <div className="mt-2 text-xs font-medium text-foreground">
441
322
  {t("meetingsRoute.guideCalendarTitle")}
442
323
  </div>
443
- <p className="mt-1 text-[11px] leading-snug text-muted-foreground">
444
- {t("meetingsRoute.guideCalendarDescription")}
445
- </p>
446
324
  </div>
447
325
  <div className="rounded-md border border-border bg-background/70 p-3">
448
326
  <IconMicrophone2 className="h-4 w-4 text-muted-foreground" />
449
327
  <div className="mt-2 text-xs font-medium text-foreground">
450
328
  {t("meetingsRoute.guideDesktopTitle")}
451
329
  </div>
452
- <p className="mt-1 text-[11px] leading-snug text-muted-foreground">
453
- {t("meetingsRoute.guideDesktopDescription")}
454
- </p>
455
330
  </div>
456
331
  <div className="rounded-md border border-border bg-background/70 p-3">
457
332
  <IconBellRinging className="h-4 w-4 text-muted-foreground" />
458
333
  <div className="mt-2 text-xs font-medium text-foreground">
459
334
  {t("meetingsRoute.guideStartTitle")}
460
335
  </div>
461
- <p className="mt-1 text-[11px] leading-snug text-muted-foreground">
462
- {t("meetingsRoute.guideStartDescription")}
463
- </p>
464
336
  </div>
465
337
  </div>
466
338
  );
467
339
  }
468
340
 
469
- function MeetingNotesGuide() {
470
- const t = useT();
471
- return (
472
- <section className="mb-6 rounded-lg border border-border bg-accent/20 p-4">
473
- <div className="mb-3 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
474
- <div className="min-w-0">
475
- <h2 className="text-sm font-semibold text-foreground">
476
- {t("meetingsRoute.howToTriggerTitle")}
477
- </h2>
478
- <p className="mt-1 max-w-2xl text-xs leading-relaxed text-muted-foreground">
479
- {t("meetingsRoute.howToTriggerDescription")}
480
- </p>
481
- </div>
482
- </div>
483
- <MeetingNotesSteps />
484
- </section>
485
- );
486
- }
487
-
488
341
  function ConnectCalendarEmptyState({
489
342
  onConnected,
490
343
  }: {
@@ -492,17 +345,17 @@ function ConnectCalendarEmptyState({
492
345
  }) {
493
346
  const t = useT();
494
347
  return (
495
- <div className="max-w-xl mx-auto mt-12">
496
- <div className="rounded-lg border border-border overflow-hidden">
497
- <div className="flex items-start gap-3 px-4 py-3.5 bg-gradient-to-br from-primary/5 via-transparent to-transparent">
348
+ <div className="mx-auto mt-12 max-w-xl">
349
+ <div className="overflow-hidden rounded-lg border border-border">
350
+ <div className="flex items-start gap-3 bg-gradient-to-br from-primary/5 via-transparent to-transparent px-4 py-3.5">
498
351
  <div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-foreground text-background">
499
352
  <IconCalendar className="h-5 w-5" />
500
353
  </div>
501
- <div className="flex-1 min-w-0">
354
+ <div className="min-w-0 flex-1">
502
355
  <div className="text-sm font-semibold text-foreground">
503
356
  {t("meetingsRoute.connectGoogleCalendar")}
504
357
  </div>
505
- <p className="mt-0.5 text-xs text-muted-foreground leading-relaxed">
358
+ <p className="mt-0.5 text-xs leading-relaxed text-muted-foreground">
506
359
  {t("meetingsRoute.desktopReminder")}
507
360
  </p>
508
361
  <div className="mt-3">
@@ -768,7 +621,7 @@ function MeetingsHeader({
768
621
  return (
769
622
  <>
770
623
  <PageHeader>
771
- <h1 className="text-base font-semibold tracking-tight truncate">
624
+ <h1 className="truncate text-base font-semibold tracking-tight">
772
625
  {t("meetingsRoute.title")}
773
626
  </h1>
774
627
  <div className="ms-auto flex items-center gap-2">
@@ -779,47 +632,29 @@ function MeetingsHeader({
779
632
  />
780
633
  </div>
781
634
  </PageHeader>
782
- <div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
783
- <div className="min-w-0 flex-1 space-y-4">
784
- <p className="text-sm text-muted-foreground">
785
- {t("meetingsRoute.intro")}
786
- </p>
787
- <div className="relative max-w-sm">
788
- <IconSearch className="absolute start-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
789
- <Input
790
- value={query}
791
- onChange={(e) => onQueryChange(e.target.value)}
792
- placeholder={t("meetingsRoute.searchPlaceholder")}
793
- className="ps-8 pe-8 h-9 text-sm"
794
- />
795
- {query && (
796
- <button
797
- type="button"
798
- onClick={() => onQueryChange("")}
799
- className="absolute end-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground cursor-pointer"
800
- aria-label={t("meetingsRoute.clearSearch")}
801
- >
802
- <IconX className="h-3.5 w-3.5" />
803
- </button>
804
- )}
805
- </div>
806
- </div>
635
+ <div className="relative mb-6">
636
+ <IconSearch className="absolute start-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
637
+ <Input
638
+ value={query}
639
+ onChange={(e) => onQueryChange(e.target.value)}
640
+ placeholder={t("meetingsRoute.searchPlaceholder")}
641
+ className="h-9 ps-8 pe-8 text-sm"
642
+ />
643
+ {query && (
644
+ <button
645
+ type="button"
646
+ onClick={() => onQueryChange("")}
647
+ className="absolute end-2 top-1/2 -translate-y-1/2 cursor-pointer text-muted-foreground hover:text-foreground"
648
+ aria-label={t("meetingsRoute.clearSearch")}
649
+ >
650
+ <IconX className="h-3.5 w-3.5" />
651
+ </button>
652
+ )}
807
653
  </div>
808
654
  </>
809
655
  );
810
656
  }
811
657
 
812
- function meetingMatches(m: Meeting, q: string): boolean {
813
- if (!q) return true;
814
- const needle = q.toLowerCase();
815
- if ((m.title || "").toLowerCase().includes(needle)) return true;
816
- for (const p of m.participants ?? []) {
817
- if ((p.name ?? "").toLowerCase().includes(needle)) return true;
818
- if ((p.email ?? "").toLowerCase().includes(needle)) return true;
819
- }
820
- return false;
821
- }
822
-
823
658
  export default function MeetingsIndexRoute() {
824
659
  const t = useT();
825
660
  const [searchParams, setSearchParams] = useSearchParams();
@@ -848,12 +683,37 @@ export default function MeetingsIndexRoute() {
848
683
  }, [query]);
849
684
 
850
685
  const queryClient = useQueryClient();
686
+ const trimmedQuery = debouncedQuery.trim();
687
+ const isSearching = trimmedQuery.length > 0;
688
+
689
+ // Tab lives in the URL so it survives reload, is linkable, and shows up in
690
+ // navigation state for the agent — same treatment as `?q=`.
691
+ const tabParam = searchParams.get("tab");
692
+ const activeTab: MeetingsTab = isMeetingsTab(tabParam) ? tabParam : "agenda";
693
+ const setActiveTab = useCallback(
694
+ (next: string) => {
695
+ setSearchParams(
696
+ (prev) => {
697
+ const params = new URLSearchParams(prev);
698
+ if (next === "agenda") params.delete("tab");
699
+ else params.set("tab", next);
700
+ return params;
701
+ },
702
+ { replace: true },
703
+ );
704
+ },
705
+ [setSearchParams],
706
+ );
851
707
 
852
708
  const accounts = useActionQuery<{ accounts: CalendarAccount[] } | undefined>(
853
709
  "list-calendar-accounts",
854
710
  {},
855
711
  { retry: false },
856
712
  );
713
+
714
+ // History is the page body: every past meeting that holds something worth
715
+ // reopening, paged rather than capped. `hasContent` (not `recordedOnly`) is
716
+ // what keeps desktop live notes without a linked recording in the list.
857
717
  const history = useInfiniteQuery({
858
718
  queryKey: ["action", "list-meetings", "history"],
859
719
  initialPageParam: 0,
@@ -868,16 +728,28 @@ export default function MeetingsIndexRoute() {
868
728
  : undefined,
869
729
  retry: false,
870
730
  });
871
- // Upcoming calendar events, read live from connected calendars. This is the
872
- // Granola-style surface that lets the user record/join a meeting that's about
873
- // to start. Poll every 30s so a freshly-added calendar event (or one moving
874
- // into the "now" window) shows up without a manual refresh.
875
- const upcomingQuery = useActionQuery<ListMeetingsResponse | undefined>(
731
+
732
+ // The agenda window, read live from connected calendars: 24h back through
733
+ // the next 30 days, so a call from earlier today is still on your day rather
734
+ // than already filed under Past. Poll every 30s so a freshly-added event (or
735
+ // one crossing the "now" marker) shows up without a manual refresh.
736
+ const agendaQuery = useActionQuery<ListMeetingsResponse | undefined>(
876
737
  "list-meetings",
877
- { view: "upcoming", includeLiveCalendar: true, limit: 50 },
738
+ { view: "agenda", includeLiveCalendar: true, limit: 50 },
878
739
  { retry: false, refetchInterval: 30_000 },
879
740
  );
880
741
 
742
+ // Title / summary / notes / attendee / transcript search, server-side. The
743
+ // list-meetings pages only cover what has been scrolled to, so filtering
744
+ // them client-side could never find an older call by what was said in it.
745
+ const searchQuery = useActionQuery<
746
+ { meetings: SearchMeetingResult[] } | undefined
747
+ >(
748
+ "search-meetings",
749
+ { query: trimmedQuery, limit: 50 },
750
+ { enabled: isSearching, retry: false },
751
+ );
752
+
881
753
  const clearCalendarConnectionWarnings = useCallback(() => {
882
754
  queryClient.setQueriesData<{ accounts: CalendarAccount[] } | undefined>(
883
755
  { queryKey: ["action", "list-calendar-accounts"] },
@@ -898,6 +770,9 @@ export default function MeetingsIndexRoute() {
898
770
  queryClient.setQueriesData<any>(
899
771
  { queryKey: ["action", "list-meetings"] },
900
772
  (prev: any) => {
773
+ // This key prefix also matches the paged history query, whose cache
774
+ // entry is {pages, pageParams}. Only patch an actual list-meetings
775
+ // response, or the patch silently grafts a field onto the wrong shape.
901
776
  if (!prev || !Array.isArray(prev.meetings)) return prev;
902
777
  return { ...prev, calendarErrors: [] };
903
778
  },
@@ -931,25 +806,32 @@ export default function MeetingsIndexRoute() {
931
806
  queryKey: ["action", "list-meetings"],
932
807
  });
933
808
  }
934
- }, [accounts, clearCalendarConnectionWarnings, history, queryClient]);
809
+ }, [accounts, clearCalendarConnectionWarnings, history, queryClient, t]);
935
810
 
936
- const meetings: Meeting[] = useMemo(
811
+ const historyMeetings: Meeting[] = useMemo(
937
812
  () => (history.data?.pages ?? []).flatMap((page) => page?.meetings ?? []),
938
813
  [history.data],
939
814
  );
940
815
 
941
- const upcomingMeetings: Meeting[] = useMemo(() => {
942
- const data = upcomingQuery.data;
816
+ const agendaMeetings: Meeting[] = useMemo(() => {
817
+ const data = agendaQuery.data;
943
818
  if (!data) return [];
944
819
  if (Array.isArray(data)) return data as Meeting[];
945
820
  return data.meetings ?? [];
946
- }, [upcomingQuery.data]);
821
+ }, [agendaQuery.data]);
947
822
 
948
823
  const calendarErrors: CalendarFetchError[] = useMemo(() => {
949
- const data = upcomingQuery.data;
824
+ const data = agendaQuery.data;
950
825
  if (!data || Array.isArray(data)) return [];
951
826
  return data.calendarErrors ?? [];
952
- }, [upcomingQuery.data]);
827
+ }, [agendaQuery.data]);
828
+
829
+ const searchResults = searchQuery.data?.meetings ?? [];
830
+ const searchSnippets = useMemo(() => {
831
+ const map = new Map<string, string | null | undefined>();
832
+ for (const m of searchResults) map.set(m.id, m.snippet);
833
+ return map;
834
+ }, [searchResults]);
953
835
 
954
836
  const calendarAccounts = accounts.data?.accounts ?? [];
955
837
  const hasCalendar = calendarAccounts.length > 0;
@@ -979,27 +861,13 @@ export default function MeetingsIndexRoute() {
979
861
  ? "Couldn't load meetings. Try again in a moment."
980
862
  : null;
981
863
 
982
- const recordedMeetings = useMemo(() => {
983
- const filtered = meetings.filter((m) => meetingMatches(m, debouncedQuery));
984
- filtered.sort(
985
- (a, b) =>
986
- new Date(b.scheduledStart).getTime() -
987
- new Date(a.scheduledStart).getTime(),
988
- );
989
- return filtered;
990
- }, [meetings, debouncedQuery]);
991
-
992
- const filteredUpcoming = useMemo(() => {
993
- const filtered = upcomingMeetings.filter((m) =>
994
- meetingMatches(m, debouncedQuery),
995
- );
996
- filtered.sort(
864
+ const agendaSorted = useMemo(() => {
865
+ return [...agendaMeetings].sort(
997
866
  (a, b) =>
998
867
  new Date(a.scheduledStart).getTime() -
999
868
  new Date(b.scheduledStart).getTime(),
1000
869
  );
1001
- return filtered;
1002
- }, [upcomingMeetings, debouncedQuery]);
870
+ }, [agendaMeetings]);
1003
871
 
1004
872
  // A calendar can need re-auth either via a live fetch error (calendarErrors)
1005
873
  // or — more commonly — because list-meetings skips non-"connected" accounts
@@ -1012,18 +880,16 @@ export default function MeetingsIndexRoute() {
1012
880
  return (
1013
881
  <>
1014
882
  <PageHeader>
1015
- <h1 className="text-base font-semibold tracking-tight truncate">
883
+ <h1 className="truncate text-base font-semibold tracking-tight">
1016
884
  {t("meetingsRoute.title")}
1017
885
  </h1>
1018
886
  </PageHeader>
1019
- <div className="p-6 max-w-6xl mx-auto w-full">
1020
- <div className="space-y-2 mb-6">
1021
- <div className="h-7 w-40 rounded bg-muted animate-pulse" />
1022
- <div className="h-4 w-64 rounded bg-muted/70 animate-pulse" />
1023
- </div>
1024
- <div className="space-y-2">
1025
- {Array.from({ length: 6 }).map((_, i) => (
1026
- <RecordedMeetingSkeleton key={i} />
887
+ <div className="mx-auto w-full max-w-3xl p-6">
888
+ <div className="mb-6 h-9 animate-pulse rounded-md bg-muted/70" />
889
+ <AgendaCardSkeleton />
890
+ <div className="mt-8 space-y-1">
891
+ {Array.from({ length: 8 }).map((_, i) => (
892
+ <MeetingHistoryRowSkeleton key={i} />
1027
893
  ))}
1028
894
  </div>
1029
895
  </div>
@@ -1035,11 +901,11 @@ export default function MeetingsIndexRoute() {
1035
901
  return (
1036
902
  <>
1037
903
  <PageHeader>
1038
- <h1 className="text-base font-semibold tracking-tight truncate">
904
+ <h1 className="truncate text-base font-semibold tracking-tight">
1039
905
  {t("meetingsRoute.title")}
1040
906
  </h1>
1041
907
  </PageHeader>
1042
- <div className="p-6 max-w-2xl mx-auto w-full">
908
+ <div className="mx-auto w-full max-w-2xl p-6">
1043
909
  <div className="rounded-md border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
1044
910
  {calendarLoadError}
1045
911
  </div>
@@ -1048,11 +914,16 @@ export default function MeetingsIndexRoute() {
1048
914
  );
1049
915
  }
1050
916
 
1051
- const nothingAtAll = meetings.length === 0 && upcomingMeetings.length === 0;
917
+ const nothingAtAll =
918
+ historyMeetings.length === 0 && agendaMeetings.length === 0;
1052
919
 
1053
- if (!hasCalendar && nothingAtAll) {
920
+ // Search reads server-side across all meetings regardless of calendar
921
+ // connection state (trashed/manually-created meetings, past imports), so a
922
+ // query in flight must still reach the search branch below rather than
923
+ // being preempted by the "connect your calendar" empty state.
924
+ if (!hasCalendar && nothingAtAll && !isSearching) {
1054
925
  return (
1055
- <div className="p-6 w-full">
926
+ <div className="w-full p-6">
1056
927
  <MeetingsHeader
1057
928
  query={query}
1058
929
  onQueryChange={setQuery}
@@ -1065,15 +936,8 @@ export default function MeetingsIndexRoute() {
1065
936
  );
1066
937
  }
1067
938
 
1068
- const hasPast = recordedMeetings.length > 0;
1069
- const hasUpcoming = filteredUpcoming.length > 0;
1070
- const upcomingLoading =
1071
- upcomingQuery.isLoading && upcomingMeetings.length === 0 && hasCalendar;
1072
- const noSearchMatches =
1073
- !!debouncedQuery && !hasPast && !hasUpcoming && !nothingAtAll;
1074
-
1075
939
  return (
1076
- <div className="p-6 max-w-6xl mx-auto w-full">
940
+ <div className="mx-auto w-full max-w-3xl p-6">
1077
941
  <MeetingsHeader
1078
942
  query={query}
1079
943
  onQueryChange={setQuery}
@@ -1086,65 +950,107 @@ export default function MeetingsIndexRoute() {
1086
950
  <CalendarReauthBanner onReconnect={handleReconnectCalendar} />
1087
951
  )}
1088
952
 
1089
- {hasCalendar && meetings.length === 0 && <MeetingNotesGuide />}
1090
-
1091
- {nothingAtAll ? (
1092
- <div className="rounded-lg border border-dashed border-border bg-accent/20 px-6 py-16 text-center">
1093
- <IconCalendar className="h-10 w-10 text-muted-foreground/50 mx-auto" />
1094
- <p className="mt-3 text-sm text-foreground font-medium">
1095
- {t("meetingsRoute.noMeetingsYet")}
1096
- </p>
1097
- <p className="mt-1 text-xs text-muted-foreground">
1098
- {t("meetingsRoute.noMeetingsDescription")}
1099
- </p>
1100
- </div>
1101
- ) : noSearchMatches ? (
1102
- <div className="rounded-lg border border-dashed border-border bg-accent/20 px-6 py-12 text-center">
1103
- <IconSearch className="h-7 w-7 text-muted-foreground/50 mx-auto" />
1104
- <p className="mt-2 text-sm text-foreground">
1105
- {t("meetingsRoute.noMeetingsMatch", { query: debouncedQuery })}
1106
- </p>
1107
- <Button
1108
- variant="ghost"
1109
- size="sm"
1110
- onClick={() => setQuery("")}
1111
- className="mt-2 cursor-pointer"
1112
- >
1113
- {t("meetingsRoute.clearSearch")}
1114
- </Button>
1115
- </div>
953
+ {isSearching ? (
954
+ searchQuery.isLoading ? (
955
+ <div className="space-y-1">
956
+ {Array.from({ length: 5 }).map((_, i) => (
957
+ <MeetingHistoryRowSkeleton key={i} />
958
+ ))}
959
+ </div>
960
+ ) : searchQuery.isError ? (
961
+ <div className="rounded-md border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
962
+ {t("meetingsRoute.searchFailed", {
963
+ defaultValue: "Couldn't search meetings. Try again in a moment.",
964
+ })}
965
+ </div>
966
+ ) : searchResults.length === 0 ? (
967
+ <div className="rounded-lg border border-dashed border-border bg-accent/20 px-6 py-12 text-center">
968
+ <IconSearch className="mx-auto h-7 w-7 text-muted-foreground/50" />
969
+ <p className="mt-2 text-sm text-foreground">
970
+ {t("meetingsRoute.noMeetingsMatch", { query: trimmedQuery })}
971
+ </p>
972
+ <Button
973
+ variant="ghost"
974
+ size="sm"
975
+ onClick={() => setQuery("")}
976
+ className="mt-2 cursor-pointer"
977
+ >
978
+ {t("meetingsRoute.clearSearch")}
979
+ </Button>
980
+ </div>
981
+ ) : (
982
+ <MeetingHistoryList
983
+ meetings={searchResults}
984
+ snippets={searchSnippets}
985
+ />
986
+ )
1116
987
  ) : (
1117
- <div className="space-y-8">
1118
- {upcomingLoading ? (
1119
- <UpcomingMeetingsLoading />
1120
- ) : (
1121
- <UpcomingMeetingsList meetings={filteredUpcoming} />
1122
- )}
1123
- <RecordedMeetingsList meetings={recordedMeetings} />
1124
- {history.hasNextPage && (
1125
- <div className="flex justify-center">
1126
- <Button
1127
- variant="outline"
1128
- size="sm"
1129
- onClick={() => history.fetchNextPage()}
1130
- disabled={history.isFetchingNextPage}
1131
- className="gap-1.5 cursor-pointer"
1132
- >
1133
- {history.isFetchingNextPage ? (
1134
- <IconLoader2 className="h-3.5 w-3.5 animate-spin" />
1135
- ) : null}
1136
- {t("meetingsRoute.loadOlder", { defaultValue: "Load older" })}
1137
- </Button>
1138
- </div>
1139
- )}
1140
- </div>
1141
- )}
988
+ <Tabs value={activeTab} onValueChange={setActiveTab}>
989
+ <TabsList className="mb-4 grid w-full max-w-xs grid-cols-2">
990
+ <TabsTrigger value="agenda" className="text-xs">
991
+ {t("meetingsRoute.agendaTab", { defaultValue: "Agenda" })}
992
+ </TabsTrigger>
993
+ <TabsTrigger value="past" className="text-xs">
994
+ {t("meetingsRoute.pastTab", { defaultValue: "Past" })}
995
+ </TabsTrigger>
996
+ </TabsList>
1142
997
 
1143
- {(history.isFetching || upcomingQuery.isFetching) && !isLoading && (
1144
- <div className="flex items-center justify-center mt-6 text-xs text-muted-foreground gap-1.5">
1145
- <IconLoader2 className="h-3.5 w-3.5 animate-spin" />
1146
- {t("meetingsRoute.refreshing")}
1147
- </div>
998
+ <TabsContent value="agenda">
999
+ {agendaQuery.isLoading && agendaMeetings.length === 0 ? (
1000
+ <AgendaCardSkeleton />
1001
+ ) : agendaSorted.length > 0 ? (
1002
+ <AgendaCard meetings={agendaSorted} />
1003
+ ) : (
1004
+ <div className="rounded-lg border border-dashed border-border bg-accent/20 px-6 py-16 text-center">
1005
+ <IconCalendar className="mx-auto h-10 w-10 text-muted-foreground/50" />
1006
+ <p className="mt-3 text-sm font-medium text-foreground">
1007
+ {t("meetingsRoute.noMeetingsYet")}
1008
+ </p>
1009
+ <p className="mt-1 text-xs text-muted-foreground">
1010
+ {t("meetingsRoute.noMeetingsDescription")}
1011
+ </p>
1012
+ </div>
1013
+ )}
1014
+ </TabsContent>
1015
+
1016
+ <TabsContent value="past" className="space-y-4">
1017
+ {historyMeetings.length > 0 ? (
1018
+ <>
1019
+ <MeetingHistoryList meetings={historyMeetings} />
1020
+ {history.hasNextPage ? (
1021
+ <div className="flex justify-center pt-2">
1022
+ <Button
1023
+ variant="outline"
1024
+ size="sm"
1025
+ onClick={() => history.fetchNextPage()}
1026
+ disabled={history.isFetchingNextPage}
1027
+ className="h-8 cursor-pointer gap-1.5 text-xs"
1028
+ >
1029
+ {history.isFetchingNextPage ? (
1030
+ <IconLoader2 className="h-3.5 w-3.5 animate-spin" />
1031
+ ) : null}
1032
+ {t("meetingsRoute.loadOlder", {
1033
+ defaultValue: "Load older",
1034
+ })}
1035
+ </Button>
1036
+ </div>
1037
+ ) : null}
1038
+ </>
1039
+ ) : (
1040
+ <div className="rounded-lg border border-dashed border-border bg-accent/20 px-6 py-16 text-center">
1041
+ <IconCalendar className="mx-auto h-10 w-10 text-muted-foreground/50" />
1042
+ <p className="mt-3 text-sm font-medium text-foreground">
1043
+ {t("meetingsRoute.noPastMeetings", {
1044
+ defaultValue: "No past meetings yet",
1045
+ })}
1046
+ </p>
1047
+ <p className="mt-1 text-xs text-muted-foreground">
1048
+ {t("meetingsRoute.noMeetingsDescription")}
1049
+ </p>
1050
+ </div>
1051
+ )}
1052
+ </TabsContent>
1053
+ </Tabs>
1148
1054
  )}
1149
1055
  </div>
1150
1056
  );