@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.
- package/corpus/README.md +1 -1
- package/corpus/templates/clips/.agents/skills/meetings/SKILL.md +11 -6
- package/corpus/templates/clips/AGENTS.md +1 -0
- package/corpus/templates/clips/actions/lib/meeting-content.ts +15 -4
- package/corpus/templates/clips/actions/list-meetings.ts +179 -42
- package/corpus/templates/clips/actions/search-meetings.ts +277 -0
- package/corpus/templates/clips/app/components/meetings/agenda-card.tsx +273 -0
- package/corpus/templates/clips/app/components/meetings/day-grouped-card.tsx +113 -0
- package/corpus/templates/clips/app/components/meetings/meeting-history-row.tsx +117 -0
- package/corpus/templates/clips/app/hooks/use-navigation-state.ts +12 -2
- package/corpus/templates/clips/app/i18n/en-US.ts +7 -2
- package/corpus/templates/clips/app/routes/_app.meetings._index.tsx +263 -357
- package/corpus/templates/clips/changelog/2026-08-14-meetings-history-is-searchable-again.md +6 -0
- package/corpus/templates/clips/desktop/design-refs/granola-ux.md +17 -0
- package/corpus/templates/content/actions/_database-source-utils.ts +29 -2
- package/corpus/templates/content/app/components/editor/SlashCommandMenu.tsx +11 -11
- package/corpus/templates/content/app/components/editor/VisualEditor.tsx +11 -50
- package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +57 -20
- package/corpus/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx +30 -0
- package/corpus/templates/content/app/components/editor/extensions/NotionExtensions.tsx +204 -51
- package/corpus/templates/content/app/global.css +4 -1
- package/corpus/templates/content/app/i18n-data.ts +30 -0
- package/corpus/templates/content/changelog/2026-08-12-toggle-blocks-now-follow-notion-style-enter-and-shift-tab-be.md +6 -0
- package/corpus/templates/content/docs/solutions/2026-08-12-toggle-summary-focus-persistence-shape.md +733 -0
- package/corpus/templates/content/shared/builder-mdx.ts +44 -4
- package/corpus/templates/slides/actions/get-layout-overflows.ts +65 -2
- package/dist/deploy/build.d.ts +6 -4
- package/dist/deploy/build.js +27 -11
- package/dist/mcp/screen-memory-stdio.d.ts +7 -7
- package/dist/notifications/routes.d.ts +3 -3
- package/dist/observability/routes.d.ts +2 -2
- package/dist/secrets/routes.d.ts +6 -6
- package/package.json +2 -2
- 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 {
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
} from "@/components/meetings/
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
221
|
-
const
|
|
222
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
<
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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="
|
|
496
|
-
<div className="rounded-lg border border-border
|
|
497
|
-
<div className="flex items-start gap-3
|
|
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="
|
|
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
|
|
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
|
|
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
|
|
783
|
-
<
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
{
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
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
|
-
|
|
872
|
-
//
|
|
873
|
-
//
|
|
874
|
-
//
|
|
875
|
-
|
|
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: "
|
|
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
|
|
811
|
+
const historyMeetings: Meeting[] = useMemo(
|
|
937
812
|
() => (history.data?.pages ?? []).flatMap((page) => page?.meetings ?? []),
|
|
938
813
|
[history.data],
|
|
939
814
|
);
|
|
940
815
|
|
|
941
|
-
const
|
|
942
|
-
const 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
|
-
}, [
|
|
821
|
+
}, [agendaQuery.data]);
|
|
947
822
|
|
|
948
823
|
const calendarErrors: CalendarFetchError[] = useMemo(() => {
|
|
949
|
-
const data =
|
|
824
|
+
const data = agendaQuery.data;
|
|
950
825
|
if (!data || Array.isArray(data)) return [];
|
|
951
826
|
return data.calendarErrors ?? [];
|
|
952
|
-
}, [
|
|
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
|
|
983
|
-
|
|
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
|
-
|
|
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
|
|
883
|
+
<h1 className="truncate text-base font-semibold tracking-tight">
|
|
1016
884
|
{t("meetingsRoute.title")}
|
|
1017
885
|
</h1>
|
|
1018
886
|
</PageHeader>
|
|
1019
|
-
<div className="
|
|
1020
|
-
<div className="
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
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
|
|
904
|
+
<h1 className="truncate text-base font-semibold tracking-tight">
|
|
1039
905
|
{t("meetingsRoute.title")}
|
|
1040
906
|
</h1>
|
|
1041
907
|
</PageHeader>
|
|
1042
|
-
<div className="
|
|
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 =
|
|
917
|
+
const nothingAtAll =
|
|
918
|
+
historyMeetings.length === 0 && agendaMeetings.length === 0;
|
|
1052
919
|
|
|
1053
|
-
|
|
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
|
|
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="
|
|
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
|
-
{
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
<
|
|
1098
|
-
{t("meetingsRoute.
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
<
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
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
|
-
<
|
|
1118
|
-
|
|
1119
|
-
<
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
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
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
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
|
);
|