@agent-native/core 0.157.5 → 0.157.7

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 (109) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/agent-native.config.ts +5 -0
  3. package/corpus/templates/analytics/netlify.toml +1 -0
  4. package/corpus/templates/analytics/server/lib/first-party-analytics-backend.ts +23 -5
  5. package/corpus/templates/analytics/server/plugins/agent-chat.ts +4 -0
  6. package/corpus/templates/assets/netlify.toml +3 -0
  7. package/corpus/templates/brain/netlify.toml +3 -0
  8. package/corpus/templates/calendar/agent-native.config.ts +5 -0
  9. package/corpus/templates/calendar/app/components/layout/AppLayout.tsx +4 -1
  10. package/corpus/templates/calendar/netlify.toml +3 -0
  11. package/corpus/templates/chat/agent-native.config.ts +5 -0
  12. package/corpus/templates/chat/netlify.toml +3 -0
  13. package/corpus/templates/clips/actions/create-meeting.ts +19 -1
  14. package/corpus/templates/clips/actions/lib/meeting-content.ts +28 -0
  15. package/corpus/templates/clips/actions/list-meetings.ts +33 -11
  16. package/corpus/templates/clips/app/components/meetings/bullet-link.tsx +48 -25
  17. package/corpus/templates/clips/app/components/meetings/canvas-editor.tsx +7 -6
  18. package/corpus/templates/clips/app/components/meetings/transcript-bubbles.tsx +388 -178
  19. package/corpus/templates/clips/app/components/player/transcript-panel.tsx +7 -13
  20. package/corpus/templates/clips/app/components/transcript/transcript-segment-row.tsx +98 -0
  21. package/corpus/templates/clips/app/i18n/en-US.ts +5 -0
  22. package/corpus/templates/clips/app/lib/meeting-history-query.ts +11 -0
  23. package/corpus/templates/clips/app/lib/public-meeting.ts +4 -0
  24. package/corpus/templates/clips/app/routes/_app.meetings.$meetingId.tsx +49 -31
  25. package/corpus/templates/clips/app/routes/_app.meetings._index.tsx +47 -21
  26. package/corpus/templates/clips/app/routes/share.meeting.$meetingId.tsx +15 -1
  27. package/corpus/templates/clips/netlify.toml +8 -4
  28. package/corpus/templates/clips/server/routes/api/public-meeting.get.ts +10 -0
  29. package/corpus/templates/clips/shared/transcript-segments.ts +20 -2
  30. package/corpus/templates/content/netlify.toml +3 -0
  31. package/corpus/templates/design/app/i18n-data.ts +2 -0
  32. package/corpus/templates/design/netlify.toml +3 -0
  33. package/corpus/templates/dispatch/netlify.toml +3 -0
  34. package/corpus/templates/factory/netlify.toml +3 -0
  35. package/corpus/templates/forms/netlify.toml +3 -0
  36. package/corpus/templates/macros/netlify.toml +3 -0
  37. package/corpus/templates/mail/agent-native.config.ts +5 -0
  38. package/corpus/templates/mail/netlify.toml +3 -0
  39. package/corpus/templates/plan/netlify.toml +3 -0
  40. package/corpus/templates/slides/netlify.toml +3 -0
  41. package/dist/agent/harness/acp-adapter.js +17 -2
  42. package/dist/agent/harness/ai-sdk-adapter.js +14 -3
  43. package/dist/agent/harness/background.js +10 -5
  44. package/dist/agent/harness/hosted.d.ts +20 -0
  45. package/dist/agent/harness/hosted.js +100 -0
  46. package/dist/agent/harness/runner.js +100 -77
  47. package/dist/agent/observational-memory/observer.js +12 -0
  48. package/dist/agent/production-agent.d.ts +3 -0
  49. package/dist/agent/production-agent.js +74 -4
  50. package/dist/agent/thread-data-builder.d.ts +12 -0
  51. package/dist/agent/thread-data-builder.js +23 -0
  52. package/dist/agent/types.d.ts +6 -0
  53. package/dist/client/AgentPanel.d.ts +5 -1
  54. package/dist/client/AgentPanel.js +113 -17
  55. package/dist/client/AgentTaskCard.d.ts +2 -2
  56. package/dist/client/AgentTaskCard.js +8 -22
  57. package/dist/client/AssistantChat.d.ts +8 -0
  58. package/dist/client/AssistantChat.js +106 -59
  59. package/dist/client/MultiTabAssistantChat.js +50 -6
  60. package/dist/client/NewWorkspaceAppFlow.js +7 -1
  61. package/dist/client/agent-chat-adapter.d.ts +6 -0
  62. package/dist/client/agent-chat-adapter.js +36 -8
  63. package/dist/client/app-chat-sidebar.d.ts +5 -0
  64. package/dist/client/app-chat-sidebar.js +53 -9
  65. package/dist/client/chat/composer-draft.d.ts +4 -0
  66. package/dist/client/chat/composer-draft.js +63 -0
  67. package/dist/client/chat-first/apps-rail.js +10 -1
  68. package/dist/client/chat-first/primary-nav.js +10 -5
  69. package/dist/client/frame-protocol.d.ts +1 -0
  70. package/dist/client/hooks/index.d.ts +1 -1
  71. package/dist/client/hooks/index.js +1 -1
  72. package/dist/client/i18n.js +5 -2
  73. package/dist/client/progress/RunsTray.js +129 -16
  74. package/dist/client/settings/SettingsRow.js +1 -1
  75. package/dist/client/use-chat-threads.js +11 -6
  76. package/dist/collab/routes.d.ts +1 -1
  77. package/dist/collab/struct-routes.d.ts +1 -1
  78. package/dist/config.d.ts +11 -0
  79. package/dist/config.js +52 -0
  80. package/dist/deploy/build.js +28 -11
  81. package/dist/extensions/actions.js +22 -1
  82. package/dist/hosted-harness/actions/get-hosted-harness-config.d.ts +2 -0
  83. package/dist/hosted-harness/actions/get-hosted-harness-config.js +13 -0
  84. package/dist/hosted-harness/actions/set-hosted-harness-enabled.d.ts +4 -0
  85. package/dist/hosted-harness/actions/set-hosted-harness-enabled.js +32 -0
  86. package/dist/localization/default-messages.d.ts +4 -1
  87. package/dist/localization/default-messages.js +5 -2
  88. package/dist/observability/routes.d.ts +5 -5
  89. package/dist/provider-api/actions/custom-provider-registration.d.ts +6 -6
  90. package/dist/provider-api/actions/provider-api.d.ts +4 -4
  91. package/dist/resources/handlers.d.ts +1 -1
  92. package/dist/secrets/routes.d.ts +9 -9
  93. package/dist/server/action-discovery.js +12 -0
  94. package/dist/server/agent-chat-plugin.js +24 -4
  95. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  96. package/dist/server/agent-teams.d.ts +20 -12
  97. package/dist/server/agent-teams.js +69 -35
  98. package/dist/server/hosted-harness-policy.d.ts +17 -0
  99. package/dist/server/hosted-harness-policy.js +38 -0
  100. package/dist/server/realtime-token.d.ts +1 -1
  101. package/dist/server/request-context.d.ts +4 -0
  102. package/dist/server/transcribe-voice.d.ts +1 -1
  103. package/dist/styles/agent-native.css +39 -0
  104. package/dist/templates/chat/agent-native.config.ts +5 -0
  105. package/dist/templates/chat/netlify.toml +3 -0
  106. package/dist/vite/action-types-plugin.js +8 -0
  107. package/package.json +4 -2
  108. package/src/templates/chat/agent-native.config.ts +5 -0
  109. package/src/templates/chat/netlify.toml +3 -0
@@ -26,7 +26,7 @@ import {
26
26
  } from "@/components/ui/tooltip";
27
27
  import { cn } from "@/lib/utils";
28
28
 
29
- import { msToClock } from "./scrubber";
29
+ import { TranscriptSegmentRow } from "../transcript/transcript-segment-row";
30
30
 
31
31
  export interface TranscriptSegment {
32
32
  startMs: number;
@@ -299,9 +299,10 @@ export function TranscriptPanel(props: TranscriptPanelProps) {
299
299
  const isActive = displaySegments[activeIndex] === seg;
300
300
  return (
301
301
  <li key={seg.startMs}>
302
- <div
303
- role="button"
304
- tabIndex={0}
302
+ <TranscriptSegmentRow
303
+ startMs={seg.startMs}
304
+ active={isActive}
305
+ gutter="panel"
305
306
  onClick={(event) => {
306
307
  if (hasSelectionWithin(event.currentTarget)) return;
307
308
  onSeek(seg.startMs);
@@ -311,24 +312,17 @@ export function TranscriptPanel(props: TranscriptPanelProps) {
311
312
  event.preventDefault();
312
313
  onSeek(seg.startMs);
313
314
  }}
314
- className={cn(
315
- "flex w-full cursor-pointer items-start gap-3 px-3 py-2 text-left hover:bg-accent/50",
316
- isActive && "bg-accent",
317
- )}
318
315
  >
319
- <span className="shrink-0 pt-0.5 font-mono text-[11px] tabular-nums text-muted-foreground">
320
- {msToClock(seg.startMs)}
321
- </span>
322
316
  <span
323
317
  className={cn(
324
- "text-sm leading-relaxed",
318
+ "text-sm leading-normal",
325
319
  isActive ? "text-foreground" : "text-foreground/80",
326
320
  )}
327
321
  dangerouslySetInnerHTML={{
328
322
  __html: highlight(seg.text, query),
329
323
  }}
330
324
  />
331
- </div>
325
+ </TranscriptSegmentRow>
332
326
  </li>
333
327
  );
334
328
  })}
@@ -0,0 +1,98 @@
1
+ import type { KeyboardEvent, MouseEvent, ReactNode, Ref } from "react";
2
+
3
+ import { cn } from "@/lib/utils";
4
+
5
+ export interface TranscriptSegmentRowProps {
6
+ startMs: number;
7
+ children: ReactNode;
8
+ active?: boolean;
9
+ /** Momentary jump-to-segment flash, rendered as its own overlay layer so
10
+ * its fade-out is never racing the row's own hover/active transition. */
11
+ highlighted?: boolean;
12
+ onClick?: (event: MouseEvent<HTMLDivElement>) => void;
13
+ onKeyDown?: (event: KeyboardEvent<HTMLDivElement>) => void;
14
+ segmentRef?: Ref<HTMLDivElement>;
15
+ tabIndex?: number;
16
+ gutter?: "content" | "panel";
17
+ className?: string;
18
+ }
19
+
20
+ export function formatTranscriptTimestamp(ms: number): string {
21
+ if (!Number.isFinite(ms) || ms < 0) return "0:00";
22
+ const total = Math.floor(ms / 1000);
23
+ const hours = Math.floor(total / 3600);
24
+ const minutes = Math.floor((total % 3600) / 60);
25
+ const seconds = total % 60;
26
+ if (hours > 0) {
27
+ return `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
28
+ }
29
+ return `${minutes}:${String(seconds).padStart(2, "0")}`;
30
+ }
31
+
32
+ export function TranscriptSegmentRow({
33
+ startMs,
34
+ children,
35
+ active = false,
36
+ highlighted = false,
37
+ onClick,
38
+ onKeyDown,
39
+ segmentRef,
40
+ tabIndex,
41
+ gutter = "content",
42
+ className,
43
+ }: TranscriptSegmentRowProps) {
44
+ const interactive = Boolean(onClick);
45
+ const rowTabIndex = tabIndex ?? (interactive ? 0 : undefined);
46
+ const isFocusable = rowTabIndex !== undefined;
47
+
48
+ return (
49
+ <div
50
+ ref={segmentRef}
51
+ role={interactive ? "button" : isFocusable ? "group" : undefined}
52
+ tabIndex={rowTabIndex}
53
+ onClick={onClick}
54
+ onKeyDown={onKeyDown}
55
+ className={cn(
56
+ "group/segment relative flex items-baseline gap-4 rounded-md py-1.5 text-left text-sm leading-normal transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
57
+ // The meeting notes view puts the AI summary front and center and the
58
+ // transcript is reference material you dip into as needed, so it
59
+ // carries less visual weight there. Faded foreground, not
60
+ // text-muted-foreground — that token sits at 45% lightness (same as
61
+ // disabled/placeholder text elsewhere), which reads as inactive
62
+ // rather than merely secondary. The player's transcript panel has no
63
+ // competing summary — it's the primary content, so it stays full
64
+ // strength.
65
+ gutter === "panel" ? "text-foreground" : "text-foreground/70",
66
+ gutter === "content" ? "-mx-2 px-2" : "w-full px-3",
67
+ interactive && "cursor-pointer",
68
+ active && "bg-accent",
69
+ interactive && !active && "hover:bg-accent/50",
70
+ className,
71
+ )}
72
+ >
73
+ {/* Overlay, not a shared background class — its own opacity fade can't
74
+ be raced by the row's hover/active transition (see highlighted prop). */}
75
+ <span
76
+ aria-hidden="true"
77
+ className={cn(
78
+ "pointer-events-none absolute inset-0 rounded-md bg-yellow-400/15 transition-opacity ease-out",
79
+ highlighted ? "opacity-100 duration-200" : "opacity-0 duration-700",
80
+ )}
81
+ />
82
+ <span className="min-w-0 flex-1 whitespace-pre-wrap">{children}</span>
83
+ <span
84
+ className={cn(
85
+ "pointer-events-none w-12 shrink-0 text-right font-mono text-[10px] tabular-nums text-muted-foreground/80 transition-opacity",
86
+ // The player's transcript list is scanned by timestamp to find a
87
+ // moment — it's primary information there, not hover-reveal
88
+ // supplementary detail like it is in the summary bullets.
89
+ gutter === "panel"
90
+ ? "opacity-100"
91
+ : "opacity-0 group-hover/segment:opacity-100 group-focus-visible/segment:opacity-100 [@media(hover:none)]:opacity-100",
92
+ )}
93
+ >
94
+ {formatTranscriptTimestamp(startMs)}
95
+ </span>
96
+ </div>
97
+ );
98
+ }
@@ -1122,6 +1122,10 @@ All notable user-facing changes to Clips are documented here. Open it any time f
1122
1122
  searchNextMatch: "Next match",
1123
1123
  searchClose: "Close search",
1124
1124
  },
1125
+ bulletLink: {
1126
+ jumpToTranscript: "Jump to {{time}} in transcript",
1127
+ noMatchingMoment: "No matching moment found",
1128
+ },
1125
1129
  editorLayout: {
1126
1130
  trimmed: "Trimmed",
1127
1131
  trimFailed: "Trim failed",
@@ -1571,6 +1575,7 @@ All notable user-facing changes to Clips are documented here. Open it any time f
1571
1575
  transcriptPending: "Transcript pending",
1572
1576
  notesPending: "Notes pending",
1573
1577
  pastRecordings: "Past recordings",
1578
+ loadOlder: "Load older",
1574
1579
  calendarNeedsReconnect:
1575
1580
  "Google Calendar needs to be reconnected to keep showing your upcoming meetings.",
1576
1581
  connectGoogleCalendar: "Connect Google Calendar",
@@ -0,0 +1,11 @@
1
+ export const MEETING_HISTORY_PAGE_SIZE = 50;
2
+
3
+ export function buildMeetingHistoryQuery(offset: number) {
4
+ return {
5
+ view: "past" as const,
6
+ hasContent: true,
7
+ includeLiveCalendar: false,
8
+ limit: MEETING_HISTORY_PAGE_SIZE,
9
+ offset,
10
+ };
11
+ }
@@ -36,6 +36,10 @@ export interface PublicMeeting {
36
36
  bullets: PublicMeetingBullet[];
37
37
  participants: PublicMeetingParticipant[];
38
38
  actionItems: PublicMeetingActionItem[];
39
+ /** Recording owner's email — resolves "mic" segments to the actual
40
+ * recorder for calendar meetings where they aren't the event organizer.
41
+ * Already no more exposed than the per-participant emails on this page. */
42
+ ownerEmail: string | null;
39
43
  actualStart: string | null;
40
44
  actualEnd: string | null;
41
45
  transcriptStatus: string | null;
@@ -92,6 +92,7 @@ interface Bullet {
92
92
  interface Meeting {
93
93
  id: string;
94
94
  title: string;
95
+ ownerEmail?: string | null;
95
96
  scheduledStart: string;
96
97
  scheduledEnd?: string | null;
97
98
  actualStart?: string | null;
@@ -265,7 +266,7 @@ function ActionItemsByPerson({
265
266
  key={
266
267
  it.id ?? `${it.assigneeEmail ?? "?"}:${it.text}:${index}`
267
268
  }
268
- className="flex items-start gap-2 text-xs leading-relaxed"
269
+ className="flex items-start gap-2 text-sm leading-relaxed"
269
270
  >
270
271
  <button
271
272
  type="button"
@@ -730,12 +731,6 @@ export default function MeetingDetailRoute() {
730
731
  </Button>
731
732
  </DropdownMenuTrigger>
732
733
  <DropdownMenuContent align="end" className="w-44">
733
- {hasSummary && !finalize.isPending && (
734
- <DropdownMenuItem onSelect={handleFinalize}>
735
- <IconRefresh className="mr-2 h-4 w-4" />
736
- {t("meetingDetail.regenerateNotes")}
737
- </DropdownMenuItem>
738
- )}
739
734
  {isLive && (
740
735
  <DropdownMenuItem
741
736
  onSelect={(event) => {
@@ -880,16 +875,37 @@ export default function MeetingDetailRoute() {
880
875
  notesJustArrived && "animate-in fade-in duration-500",
881
876
  )}
882
877
  >
883
- <div className="flex shrink-0 items-center justify-between gap-2 border-b border-border px-4 py-2.5">
878
+ <div className="flex h-11 shrink-0 items-center justify-between gap-2 border-b border-border px-4">
884
879
  <div className="text-xs font-medium">
885
880
  {t("meetingDetail.summary")}
886
881
  </div>
887
- {finalize.isPending && (
888
- <span className="flex items-center gap-1 text-[10px] text-muted-foreground">
889
- <IconLoader2 className="h-3 w-3 animate-spin" />
890
- {t("meetingDetail.working")}
891
- </span>
892
- )}
882
+ <div className="flex items-center gap-2">
883
+ {finalize.isPending && (
884
+ <span className="flex items-center gap-1 text-[10px] text-muted-foreground">
885
+ <IconLoader2 className="h-3 w-3 animate-spin" />
886
+ {t("meetingDetail.working")}
887
+ </span>
888
+ )}
889
+ {canEdit && hasSummary && (
890
+ <Tooltip>
891
+ <TooltipTrigger asChild>
892
+ <Button
893
+ size="icon"
894
+ variant="ghost"
895
+ className="h-7 w-7 cursor-pointer"
896
+ aria-label={t("meetingDetail.regenerateNotes")}
897
+ disabled={finalize.isPending}
898
+ onClick={handleFinalize}
899
+ >
900
+ <IconRefresh className="h-3.5 w-3.5" />
901
+ </Button>
902
+ </TooltipTrigger>
903
+ <TooltipContent>
904
+ {t("meetingDetail.regenerateNotes")}
905
+ </TooltipContent>
906
+ </Tooltip>
907
+ )}
908
+ </div>
893
909
  </div>
894
910
 
895
911
  <div className="min-h-0 flex-1 overflow-y-auto">
@@ -905,7 +921,7 @@ export default function MeetingDetailRoute() {
905
921
  segments={segments}
906
922
  onJumpTo={handleJumpToSegment}
907
923
  >
908
- <div className="flex gap-2 text-sm leading-relaxed text-muted-foreground">
924
+ <div className="flex gap-2 text-sm leading-relaxed text-foreground">
909
925
  <span>•</span>
910
926
  <span className="flex-1">{b}</span>
911
927
  </div>
@@ -934,13 +950,22 @@ export default function MeetingDetailRoute() {
934
950
 
935
951
  {/* Transcript pane — plain agent-chat-style text layout */}
936
952
  <div className="rounded-lg border border-border bg-background min-h-[480px] lg:min-h-0 overflow-hidden flex flex-col">
937
- <div className="flex shrink-0 items-center justify-between gap-2 border-b border-border px-4 py-2.5 bg-background">
938
- <div className="flex items-center gap-1.5 text-xs font-medium">
939
- <IconNotes className="h-3.5 w-3.5" />
940
- {t("meetingDetail.transcript")}
941
- </div>
942
- <div className="flex items-center gap-2">
943
- {segments.length > 0 && (
953
+ <TranscriptBubbles
954
+ segments={segments}
955
+ isLive={isLive}
956
+ participants={meeting.participants ?? []}
957
+ ownerEmail={meeting.ownerEmail}
958
+ registerScrollTo={(fn) => {
959
+ transcriptScrollToRef.current = fn;
960
+ }}
961
+ title={
962
+ <>
963
+ <IconNotes className="h-3.5 w-3.5" />
964
+ {t("meetingDetail.transcript")}
965
+ </>
966
+ }
967
+ headerActions={
968
+ segments.length > 0 && (
944
969
  <Tooltip>
945
970
  <TooltipTrigger asChild>
946
971
  <Button
@@ -961,15 +986,8 @@ export default function MeetingDetailRoute() {
961
986
  {t("meetingDetail.copyFullTranscript")}
962
987
  </TooltipContent>
963
988
  </Tooltip>
964
- )}
965
- </div>
966
- </div>
967
- <TranscriptBubbles
968
- segments={segments}
969
- isLive={isLive}
970
- registerScrollTo={(fn) => {
971
- transcriptScrollToRef.current = fn;
972
- }}
989
+ )
990
+ }
973
991
  />
974
992
  </div>
975
993
  </div>
@@ -1,5 +1,5 @@
1
1
  import { agentNativePath } from "@agent-native/core/client/api-path";
2
- import { useActionQuery } from "@agent-native/core/client/hooks";
2
+ import { callAction, useActionQuery } from "@agent-native/core/client/hooks";
3
3
  import { useT } from "@agent-native/core/client/i18n";
4
4
  import {
5
5
  IconAlertTriangle,
@@ -17,7 +17,7 @@ import {
17
17
  IconSettings,
18
18
  IconX,
19
19
  } from "@tabler/icons-react";
20
- import { useQueryClient } from "@tanstack/react-query";
20
+ import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
21
21
  import { useCallback, useEffect, useMemo, useState } from "react";
22
22
  import { NavLink, useSearchParams } from "react-router";
23
23
  import { toast } from "sonner";
@@ -50,6 +50,10 @@ import {
50
50
  } from "@/components/ui/dropdown-menu";
51
51
  import { Input } from "@/components/ui/input";
52
52
  import enMessages from "@/i18n/en-US";
53
+ import {
54
+ buildMeetingHistoryQuery,
55
+ MEETING_HISTORY_PAGE_SIZE,
56
+ } from "@/lib/meeting-history-query";
53
57
 
54
58
  export function meta() {
55
59
  return [{ title: enMessages.meetingsRoute.pageTitle }];
@@ -88,6 +92,7 @@ interface CalendarFetchError {
88
92
  interface ListMeetingsResponse {
89
93
  meetings?: Meeting[];
90
94
  calendarErrors?: CalendarFetchError[];
95
+ hasMore?: boolean;
91
96
  }
92
97
 
93
98
  interface CalendarAccount {
@@ -849,13 +854,20 @@ export default function MeetingsIndexRoute() {
849
854
  {},
850
855
  { retry: false },
851
856
  );
852
- const meetingsQuery = useActionQuery<
853
- { meetings: Meeting[] } | Meeting[] | undefined
854
- >(
855
- "list-meetings",
856
- { view: "past", recordedOnly: true, includeLiveCalendar: false },
857
- { retry: false },
858
- );
857
+ const history = useInfiniteQuery({
858
+ queryKey: ["action", "list-meetings", "history"],
859
+ initialPageParam: 0,
860
+ queryFn: ({ pageParam, signal }) =>
861
+ callAction("list-meetings", buildMeetingHistoryQuery(pageParam), {
862
+ method: "GET",
863
+ signal,
864
+ }) as Promise<ListMeetingsResponse>,
865
+ getNextPageParam: (lastPage, allPages) =>
866
+ lastPage?.hasMore
867
+ ? allPages.length * MEETING_HISTORY_PAGE_SIZE
868
+ : undefined,
869
+ retry: false,
870
+ });
859
871
  // Upcoming calendar events, read live from connected calendars. This is the
860
872
  // Granola-style surface that lets the user record/join a meeting that's about
861
873
  // to start. Poll every 30s so a freshly-added calendar event (or one moving
@@ -886,7 +898,7 @@ export default function MeetingsIndexRoute() {
886
898
  queryClient.setQueriesData<any>(
887
899
  { queryKey: ["action", "list-meetings"] },
888
900
  (prev: any) => {
889
- if (!prev || Array.isArray(prev)) return prev;
901
+ if (!prev || !Array.isArray(prev.meetings)) return prev;
890
902
  return { ...prev, calendarErrors: [] };
891
903
  },
892
904
  );
@@ -905,7 +917,7 @@ export default function MeetingsIndexRoute() {
905
917
  if (connected) break;
906
918
  await new Promise((resolve) => window.setTimeout(resolve, 500));
907
919
  }
908
- await meetingsQuery.refetch();
920
+ await history.refetch();
909
921
  if (connected) toast.success(t("meetingsRoute.calendarConnected"));
910
922
  } catch (err) {
911
923
  toast.error(
@@ -919,14 +931,12 @@ export default function MeetingsIndexRoute() {
919
931
  queryKey: ["action", "list-meetings"],
920
932
  });
921
933
  }
922
- }, [accounts, clearCalendarConnectionWarnings, meetingsQuery, queryClient]);
934
+ }, [accounts, clearCalendarConnectionWarnings, history, queryClient]);
923
935
 
924
- const meetings: Meeting[] = useMemo(() => {
925
- const data = meetingsQuery.data;
926
- if (!data) return [];
927
- if (Array.isArray(data)) return data;
928
- return data.meetings ?? [];
929
- }, [meetingsQuery.data]);
936
+ const meetings: Meeting[] = useMemo(
937
+ () => (history.data?.pages ?? []).flatMap((page) => page?.meetings ?? []),
938
+ [history.data],
939
+ );
930
940
 
931
941
  const upcomingMeetings: Meeting[] = useMemo(() => {
932
942
  const data = upcomingQuery.data;
@@ -961,11 +971,11 @@ export default function MeetingsIndexRoute() {
961
971
  );
962
972
  }, [handleCalendarConnected]);
963
973
 
964
- const isLoading = accounts.isLoading || meetingsQuery.isLoading;
974
+ const isLoading = accounts.isLoading || history.isLoading;
965
975
 
966
976
  const calendarLoadError = accounts.isError
967
977
  ? "Couldn't check your calendar connection. Try again in a moment."
968
- : meetingsQuery.isError
978
+ : history.isError
969
979
  ? "Couldn't load meetings. Try again in a moment."
970
980
  : null;
971
981
 
@@ -1111,10 +1121,26 @@ export default function MeetingsIndexRoute() {
1111
1121
  <UpcomingMeetingsList meetings={filteredUpcoming} />
1112
1122
  )}
1113
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
+ )}
1114
1140
  </div>
1115
1141
  )}
1116
1142
 
1117
- {(meetingsQuery.isFetching || upcomingQuery.isFetching) && !isLoading && (
1143
+ {(history.isFetching || upcomingQuery.isFetching) && !isLoading && (
1118
1144
  <div className="flex items-center justify-center mt-6 text-xs text-muted-foreground gap-1.5">
1119
1145
  <IconLoader2 className="h-3.5 w-3.5 animate-spin" />
1120
1146
  {t("meetingsRoute.refreshing")}
@@ -86,6 +86,7 @@ export async function loader({ params, url }: LoaderFunctionArgs) {
86
86
  scheduledStart: schema.meetings.scheduledStart,
87
87
  summaryMd: schema.meetings.summaryMd,
88
88
  bulletsJson: schema.meetings.bulletsJson,
89
+ ownerEmail: schema.meetings.ownerEmail,
89
90
  actualStart: schema.meetings.actualStart,
90
91
  actualEnd: schema.meetings.actualEnd,
91
92
  transcriptStatus: schema.meetings.transcriptStatus,
@@ -169,6 +170,15 @@ export async function loader({ params, url }: LoaderFunctionArgs) {
169
170
  }
170
171
  } catch {}
171
172
 
173
+ // The owner's email is only safe to disclose here when it's already public
174
+ // via the attendee list — an unauthenticated viewer must never learn an
175
+ // account email that isn't otherwise visible on this page.
176
+ const ownerEmailIsPublic = participants.some(
177
+ (participant) =>
178
+ participant.email.trim().toLowerCase() ===
179
+ meeting.ownerEmail?.trim().toLowerCase(),
180
+ );
181
+
172
182
  return shareMeetingLoaderData(
173
183
  {
174
184
  meeting: {
@@ -179,6 +189,7 @@ export async function loader({ params, url }: LoaderFunctionArgs) {
179
189
  bullets,
180
190
  participants,
181
191
  actionItems,
192
+ ownerEmail: ownerEmailIsPublic ? meeting.ownerEmail : null,
182
193
  actualStart: meeting.actualStart,
183
194
  actualEnd: meeting.actualEnd,
184
195
  transcriptStatus: meeting.transcriptStatus,
@@ -363,6 +374,7 @@ export default function ShareMeetingRoute() {
363
374
  (participant) => ({
364
375
  email: participant.email,
365
376
  name: participant.name ?? undefined,
377
+ isOrganizer: participant.isOrganizer,
366
378
  }),
367
379
  );
368
380
  const transcript = meeting.transcript;
@@ -449,7 +461,7 @@ export default function ShareMeetingRoute() {
449
461
  {meeting.bullets.map((bullet, index) => (
450
462
  <li
451
463
  key={index}
452
- className="flex gap-2 text-sm leading-relaxed text-muted-foreground"
464
+ className="flex gap-2 text-sm leading-relaxed text-foreground"
453
465
  >
454
466
  <span>•</span>
455
467
  <span className="flex-1">{bullet.text}</span>
@@ -522,6 +534,8 @@ export default function ShareMeetingRoute() {
522
534
  <TranscriptBubbles
523
535
  segments={transcript.segments}
524
536
  isLive={false}
537
+ participants={attendees}
538
+ ownerEmail={meeting.ownerEmail}
525
539
  />
526
540
  </div>
527
541
  ) : transcript.fullText ? (
@@ -13,11 +13,15 @@ AUTO_CREATE_DEFAULT_ORG = "1"
13
13
  # Emit the shared 15-minute Netlify background function used by agent chat and
14
14
  # Clips post-recording transcription/seekability workers.
15
15
  AGENT_CHAT_DURABLE_BACKGROUND = "true"
16
+
17
+ [context.production.environment]
18
+ AGENT_NATIVE_HOSTED_HARNESS = "true"
19
+
16
20
  # Netlify Functions run on linux/x64 (AWS Lambda). This tells the deploy build
17
- # to bundle the matching ffmpeg-static binary into the server bundle so the
18
- # server-side audio-only transcription fallback (analyzeAudioSignal / audio
19
- # extraction) can spawn ffmpeg. Without it ffmpeg-static is skipped at build
20
- # time and request-transcript fails at runtime with FFMPEG_UNAVAILABLE.
21
+ # to bundle the matching ffmpeg-static binary into the server bundle so frame
22
+ # extraction, WebM seekability, filmstrips, and audio-only transcription work
23
+ # in production. The deploy guard grants this known media-runtime payload its
24
+ # explicit allowance instead of treating it as accidental bundle growth.
21
25
  AGENT_NATIVE_SERVERLESS_FFMPEG_ARCH = "x64"
22
26
 
23
27
  # A2A delegation calls run a full agent loop (LLM + tool calls + DB queries).
@@ -148,6 +148,15 @@ export default defineEventHandler(async (event) => {
148
148
  : [];
149
149
  const role = access.role;
150
150
 
151
+ // The owner's email is only safe to disclose here when it's already
152
+ // public via the attendee list — an unauthenticated viewer must never
153
+ // learn an account email that isn't otherwise visible on this page.
154
+ const ownerEmailIsPublic = participants.some(
155
+ (participant) =>
156
+ participant.email.trim().toLowerCase() ===
157
+ meeting.ownerEmail?.trim().toLowerCase(),
158
+ );
159
+
151
160
  return {
152
161
  meeting: {
153
162
  id: meeting.id,
@@ -160,6 +169,7 @@ export default defineEventHandler(async (event) => {
160
169
  bullets: parseBullets(meeting.bulletsJson),
161
170
  participants,
162
171
  actionItems,
172
+ ownerEmail: ownerEmailIsPublic ? meeting.ownerEmail : null,
163
173
  ...(meeting.shareTranscript
164
174
  ? {
165
175
  transcript: transcript
@@ -3,6 +3,7 @@ export interface TranscriptSegment {
3
3
  endMs: number;
4
4
  text: string;
5
5
  source?: "mic" | "system";
6
+ speaker?: string | null;
6
7
  }
7
8
 
8
9
  const MAX_WORDS_PER_CAPTION = 7;
@@ -27,6 +28,8 @@ export function parseTranscriptSegments(
27
28
  segment?.source === "mic" || segment?.source === "system"
28
29
  ? segment.source
29
30
  : undefined,
31
+ speaker:
32
+ typeof segment?.speaker === "string" ? segment.speaker : undefined,
30
33
  }))
31
34
  .filter(
32
35
  (segment) =>
@@ -43,6 +46,8 @@ export function parseTranscriptSegments(
43
46
  export function buildCaptionSegmentsFromText(
44
47
  text: string,
45
48
  durationMs?: number | null,
49
+ source?: TranscriptSegment["source"],
50
+ speaker?: TranscriptSegment["speaker"],
46
51
  ): TranscriptSegment[] {
47
52
  const chunks = splitIntoCaptionChunks(text);
48
53
  if (chunks.length === 0) return [];
@@ -73,6 +78,8 @@ export function buildCaptionSegmentsFromText(
73
78
  startMs: cursorMs,
74
79
  endMs,
75
80
  text: chunk,
81
+ source,
82
+ speaker,
76
83
  };
77
84
  cursorMs = endMs;
78
85
  return segment;
@@ -98,7 +105,12 @@ export function normalizeTranscriptSegments({
98
105
  .join(" ")
99
106
  .trim();
100
107
  if (validSegments.length <= 1) {
101
- return buildCaptionSegmentsFromText(sourceText, durationMs);
108
+ return buildCaptionSegmentsFromText(
109
+ sourceText,
110
+ durationMs,
111
+ validSegments[0]?.source,
112
+ validSegments[0]?.speaker,
113
+ );
102
114
  }
103
115
 
104
116
  return validSegments.flatMap(splitTimedSegmentIntoCaptions);
@@ -160,7 +172,13 @@ function splitTimedSegmentIntoCaptions(
160
172
  latestEndMs,
161
173
  Math.max(cursorMs + minChunkMs, cursorMs + rawDuration),
162
174
  );
163
- const next = { startMs: cursorMs, endMs, text: chunk };
175
+ const next = {
176
+ startMs: cursorMs,
177
+ endMs,
178
+ text: chunk,
179
+ source: segment.source,
180
+ speaker: segment.speaker,
181
+ };
164
182
  cursorMs = endMs;
165
183
  return next;
166
184
  });
@@ -14,6 +14,9 @@ AGENT_RUN_SOFT_TIMEOUT_MS = "55000"
14
14
  # use the async worker so those calls are not cut off by the foreground wall.
15
15
  AGENT_CHAT_DURABLE_BACKGROUND = "true"
16
16
 
17
+ [context.production.environment]
18
+ AGENT_NATIVE_HOSTED_HARNESS = "true"
19
+
17
20
  # A2A delegation calls run a full agent loop (LLM + tool calls + DB queries).
18
21
  # The framework does not impose a default agent-run timeout; if a template sets
19
22
  # AGENT_RUN_SOFT_TIMEOUT_MS, keep it below this function timeout so the hidden
@@ -1084,6 +1084,8 @@ const enUS = {
1084
1084
  "PNG capture isn't available in read-only previews",
1085
1085
  pngSaveError: "Could not save PNG",
1086
1086
  pngExportError: "Could not export PNG",
1087
+ pdfExportError: "Could not export PDF",
1088
+ pdfDownloaded: "PDF downloaded",
1087
1089
  pdfAllScreensDownloaded: "PDF downloaded (all screens)",
1088
1090
  openScreenSvg: "Open a screen before exporting SVG",
1089
1091
  svgDownloaded: "SVG downloaded",
@@ -11,6 +11,9 @@ NITRO_PRESET = "netlify"
11
11
  NPM_CONFIG_PRODUCTION = "false"
12
12
  AGENT_CHAT_DURABLE_BACKGROUND = "true"
13
13
 
14
+ [context.production.environment]
15
+ AGENT_NATIVE_HOSTED_HARNESS = "true"
16
+
14
17
  # Emit the Netlify background function used by Design's durable agent-chat opt-in.
15
18
  # The normal server timeout still protects non-background HTTP handlers.
16
19
  [functions."*"]