@agent-native/core 0.84.18 → 0.84.21

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 (157) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +21 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/a2a/caller-auth.ts +37 -8
  5. package/corpus/core/src/a2a/client.ts +178 -67
  6. package/corpus/core/src/agent/production-agent.ts +19 -3
  7. package/corpus/core/src/agent/run-store.ts +180 -13
  8. package/corpus/core/src/agent/types.ts +1 -1
  9. package/corpus/core/src/cli/pr-visual-recap-workflow.ts +1 -1
  10. package/corpus/core/src/cli/recap.ts +43 -34
  11. package/corpus/core/src/client/AssistantChat.tsx +49 -0
  12. package/corpus/core/src/client/PoweredByBadge.tsx +70 -17
  13. package/corpus/core/src/client/active-run-state.ts +30 -0
  14. package/corpus/core/src/client/agent-chat-adapter.ts +5 -1
  15. package/corpus/core/src/client/extensions/EmbeddedExtension.tsx +41 -3
  16. package/corpus/core/src/client/extensions/ExtensionViewer.tsx +133 -0
  17. package/corpus/core/src/client/extensions/ExtensionsSidebarSection.tsx +2 -2
  18. package/corpus/core/src/client/i18n.tsx +10 -5
  19. package/corpus/core/src/client/sse-event-processor.ts +147 -5
  20. package/corpus/core/src/extensions/html-shell.ts +17 -3
  21. package/corpus/core/src/extensions/theme.ts +39 -0
  22. package/corpus/core/src/file-upload/builder.ts +35 -14
  23. package/corpus/core/src/file-upload/types.ts +3 -0
  24. package/corpus/core/src/integrations/a2a-continuation-processor.ts +49 -24
  25. package/corpus/core/src/mcp/builtin-tools.ts +3 -0
  26. package/corpus/core/src/mcp/org-directory.ts +66 -20
  27. package/corpus/core/src/scripts/call-agent.ts +7 -2
  28. package/corpus/templates/analytics/actions/provider-api-request.ts +8 -1
  29. package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +2 -1
  30. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/SqlChartCard.tsx +91 -33
  31. package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +161 -12
  32. package/corpus/templates/analytics/changelog/2026-07-01-session-replays-load-large-production-recordings-from-scoped.md +6 -0
  33. package/corpus/templates/calendar/app/pages/BookingPage.tsx +14 -8
  34. package/corpus/templates/calendar/changelog/2026-07-01-meeting-invite-pages-have-cleaner-branding-a-black-dark-mo.md +6 -0
  35. package/corpus/templates/clips/actions/create-recording.ts +7 -2
  36. package/corpus/templates/clips/actions/finalize-recording.ts +11 -0
  37. package/corpus/templates/clips/actions/get-recording-player-data.ts +4 -2
  38. package/corpus/templates/clips/actions/import-loom-recording.ts +1 -0
  39. package/corpus/templates/clips/actions/lib/loom-video.ts +16 -0
  40. package/corpus/templates/clips/app/components/player/scrubber.tsx +2 -2
  41. package/corpus/templates/clips/app/components/recorder/countdown-overlay.tsx +4 -3
  42. package/corpus/templates/clips/app/components/recorder/pre-record-panel.tsx +39 -12
  43. package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +65 -1
  44. package/corpus/templates/clips/app/components/recorder/recording-toolbar.tsx +1 -22
  45. package/corpus/templates/clips/app/routes/record.tsx +48 -8
  46. package/corpus/templates/clips/changelog/2026-07-01-chrome-recording-uploads-now-serialize-chunks-to-avoid-resum.md +6 -0
  47. package/corpus/templates/clips/changelog/2026-07-01-clip-uploads-and-loom-imports-finish-sooner-by-skipping-the-.md +6 -0
  48. package/corpus/templates/clips/changelog/2026-07-01-clip-uploads-and-playback-recover-more-reliably-from-storage.md +6 -0
  49. package/corpus/templates/clips/changelog/2026-07-01-desktop-recording-avoids-unsupported-browser-tab-capture.md +6 -0
  50. package/corpus/templates/clips/changelog/2026-07-01-embedded-player-progress-is-easier-to-see-on-dark-videos.md +6 -0
  51. package/corpus/templates/clips/changelog/2026-07-01-loom-imports-show-clear-guidance-when-loom-does-not-provide-a-downloadable-video.md +6 -0
  52. package/corpus/templates/clips/changelog/2026-07-01-preserve-in-flight-streaming-uploads-when-the-streaming-kill.md +6 -0
  53. package/corpus/templates/clips/changelog/2026-07-01-recording-controls-are-cleaner-and-the-countdown-uses-shadow.md +6 -0
  54. package/corpus/templates/clips/changelog/2026-07-01-recordings-now-recover-when-final-upload-status-gets-lost.md +6 -0
  55. package/corpus/templates/clips/chrome-extension/public/manifest.json +1 -1
  56. package/corpus/templates/clips/chrome-extension/src/finalize-recovery.ts +7 -224
  57. package/corpus/templates/clips/chrome-extension/src/offscreen.ts +94 -29
  58. package/corpus/templates/clips/desktop/src/lib/recorder.ts +87 -5
  59. package/corpus/templates/clips/server/lib/streaming-upload-mode.ts +7 -0
  60. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +14 -1
  61. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/status.get.ts +4 -1
  62. package/corpus/templates/clips/server/routes/api/video/[recordingId].get.ts +96 -1
  63. package/corpus/templates/clips/shared/finalize-recovery.ts +242 -0
  64. package/corpus/templates/design/changelog/2026-07-01-design-recovers-sooner-when-an-agent-gets-stuck-preparing-a-.md +6 -0
  65. package/corpus/templates/plan/app/global.css +1 -1
  66. package/dist/a2a/caller-auth.d.ts +1 -0
  67. package/dist/a2a/caller-auth.d.ts.map +1 -1
  68. package/dist/a2a/caller-auth.js +34 -8
  69. package/dist/a2a/caller-auth.js.map +1 -1
  70. package/dist/a2a/client.d.ts +4 -0
  71. package/dist/a2a/client.d.ts.map +1 -1
  72. package/dist/a2a/client.js +148 -58
  73. package/dist/a2a/client.js.map +1 -1
  74. package/dist/agent/production-agent.d.ts.map +1 -1
  75. package/dist/agent/production-agent.js +19 -4
  76. package/dist/agent/production-agent.js.map +1 -1
  77. package/dist/agent/run-store.d.ts +19 -6
  78. package/dist/agent/run-store.d.ts.map +1 -1
  79. package/dist/agent/run-store.js +163 -13
  80. package/dist/agent/run-store.js.map +1 -1
  81. package/dist/agent/types.d.ts +1 -0
  82. package/dist/agent/types.d.ts.map +1 -1
  83. package/dist/agent/types.js.map +1 -1
  84. package/dist/cli/pr-visual-recap-workflow.d.ts +1 -1
  85. package/dist/cli/pr-visual-recap-workflow.d.ts.map +1 -1
  86. package/dist/cli/pr-visual-recap-workflow.js +1 -1
  87. package/dist/cli/pr-visual-recap-workflow.js.map +1 -1
  88. package/dist/cli/recap.d.ts +3 -3
  89. package/dist/cli/recap.d.ts.map +1 -1
  90. package/dist/cli/recap.js +41 -34
  91. package/dist/cli/recap.js.map +1 -1
  92. package/dist/client/AssistantChat.d.ts +2 -0
  93. package/dist/client/AssistantChat.d.ts.map +1 -1
  94. package/dist/client/AssistantChat.js +36 -2
  95. package/dist/client/AssistantChat.js.map +1 -1
  96. package/dist/client/PoweredByBadge.d.ts +9 -3
  97. package/dist/client/PoweredByBadge.d.ts.map +1 -1
  98. package/dist/client/PoweredByBadge.js +47 -15
  99. package/dist/client/PoweredByBadge.js.map +1 -1
  100. package/dist/client/active-run-state.d.ts +3 -0
  101. package/dist/client/active-run-state.d.ts.map +1 -1
  102. package/dist/client/active-run-state.js +24 -0
  103. package/dist/client/active-run-state.js.map +1 -1
  104. package/dist/client/agent-chat-adapter.d.ts.map +1 -1
  105. package/dist/client/agent-chat-adapter.js +5 -1
  106. package/dist/client/agent-chat-adapter.js.map +1 -1
  107. package/dist/client/extensions/EmbeddedExtension.d.ts.map +1 -1
  108. package/dist/client/extensions/EmbeddedExtension.js +32 -3
  109. package/dist/client/extensions/EmbeddedExtension.js.map +1 -1
  110. package/dist/client/extensions/ExtensionViewer.d.ts.map +1 -1
  111. package/dist/client/extensions/ExtensionViewer.js +59 -2
  112. package/dist/client/extensions/ExtensionViewer.js.map +1 -1
  113. package/dist/client/extensions/ExtensionsSidebarSection.js +2 -2
  114. package/dist/client/extensions/ExtensionsSidebarSection.js.map +1 -1
  115. package/dist/client/i18n.d.ts +1 -1
  116. package/dist/client/i18n.d.ts.map +1 -1
  117. package/dist/client/i18n.js +6 -2
  118. package/dist/client/i18n.js.map +1 -1
  119. package/dist/client/sse-event-processor.d.ts +2 -0
  120. package/dist/client/sse-event-processor.d.ts.map +1 -1
  121. package/dist/client/sse-event-processor.js +121 -5
  122. package/dist/client/sse-event-processor.js.map +1 -1
  123. package/dist/collab/awareness.d.ts +2 -2
  124. package/dist/collab/awareness.d.ts.map +1 -1
  125. package/dist/collab/routes.d.ts +1 -1
  126. package/dist/extensions/html-shell.d.ts.map +1 -1
  127. package/dist/extensions/html-shell.js +17 -3
  128. package/dist/extensions/html-shell.js.map +1 -1
  129. package/dist/extensions/theme.d.ts +9 -0
  130. package/dist/extensions/theme.d.ts.map +1 -1
  131. package/dist/extensions/theme.js +38 -0
  132. package/dist/extensions/theme.js.map +1 -1
  133. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  134. package/dist/file-upload/builder.d.ts.map +1 -1
  135. package/dist/file-upload/builder.js +22 -8
  136. package/dist/file-upload/builder.js.map +1 -1
  137. package/dist/file-upload/types.d.ts +5 -1
  138. package/dist/file-upload/types.d.ts.map +1 -1
  139. package/dist/file-upload/types.js.map +1 -1
  140. package/dist/integrations/a2a-continuation-processor.js +43 -18
  141. package/dist/integrations/a2a-continuation-processor.js.map +1 -1
  142. package/dist/mcp/builtin-tools.d.ts.map +1 -1
  143. package/dist/mcp/builtin-tools.js +3 -0
  144. package/dist/mcp/builtin-tools.js.map +1 -1
  145. package/dist/mcp/org-directory.d.ts.map +1 -1
  146. package/dist/mcp/org-directory.js +58 -20
  147. package/dist/mcp/org-directory.js.map +1 -1
  148. package/dist/notifications/routes.d.ts +3 -3
  149. package/dist/observability/routes.d.ts +6 -6
  150. package/dist/progress/routes.d.ts +1 -1
  151. package/dist/resources/handlers.d.ts +3 -3
  152. package/dist/scripts/call-agent.d.ts.map +1 -1
  153. package/dist/scripts/call-agent.js +2 -2
  154. package/dist/scripts/call-agent.js.map +1 -1
  155. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  156. package/dist/server/transcribe-voice.d.ts +1 -1
  157. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  import {
2
+ appApiPath,
2
3
  PromptComposer,
3
- useActionQuery,
4
4
  useSendToAgentChat,
5
5
  useT,
6
6
  } from "@agent-native/core/client";
@@ -18,6 +18,7 @@ import {
18
18
  IconRoute,
19
19
  IconTimelineEvent,
20
20
  } from "@tabler/icons-react";
21
+ import { useQuery } from "@tanstack/react-query";
21
22
  import {
22
23
  type ReactNode,
23
24
  useCallback,
@@ -42,6 +43,7 @@ import {
42
43
  TooltipProvider,
43
44
  TooltipTrigger,
44
45
  } from "@/components/ui/tooltip";
46
+ import { getIdToken } from "@/lib/auth";
45
47
  import { cn } from "@/lib/utils";
46
48
 
47
49
  type SessionRecordingSummary = {
@@ -83,7 +85,20 @@ type ReplayChunkEvents = {
83
85
  unavailable?: boolean;
84
86
  };
85
87
 
86
- type SessionReplayEventsResponse = {
88
+ type SessionReplayManifestResponse = {
89
+ recording: SessionRecordingSummary;
90
+ chunks: Array<{
91
+ seq: number;
92
+ checksum: string;
93
+ byteLength: number;
94
+ eventCount: number;
95
+ startedAt: string | null;
96
+ endedAt: string | null;
97
+ bytesPath: string;
98
+ }>;
99
+ };
100
+
101
+ type SessionReplayPlaybackResponse = {
87
102
  recording: SessionRecordingSummary;
88
103
  chunks: ReplayChunkEvents[];
89
104
  eventCount: number;
@@ -122,6 +137,8 @@ const SPEED_OPTIONS = [0.5, 1, 2, 4, 8];
122
137
  const SKIP_STEP_MS = 5000;
123
138
  const MIN_IDLE_SKIP_MS = 8000;
124
139
  const IDLE_EDGE_PAD_MS = 1200;
140
+ const REPLAY_CHUNK_FETCH_CONCURRENCY = 6;
141
+ const REPLAY_CHUNK_UNAVAILABLE_MESSAGE = "Session replay chunk is unavailable";
125
142
 
126
143
  const RRWEB_EVENT_TYPE = {
127
144
  FullSnapshot: 2,
@@ -155,12 +172,7 @@ export default function SessionDetailPage() {
155
172
  const t = useT();
156
173
  const { recordingId = "" } = useParams();
157
174
  const { codeRequiredDialog } = useSendToAgentChat();
158
- const { data, isLoading, error } =
159
- useActionQuery<SessionReplayEventsResponse>(
160
- "get-session-replay-events",
161
- { recordingId, limit: 10000 },
162
- { enabled: Boolean(recordingId), staleTime: 30_000 },
163
- );
175
+ const { data, isLoading, error } = useSessionReplayPlayback(recordingId);
164
176
  const recording = data?.recording;
165
177
 
166
178
  return (
@@ -196,7 +208,9 @@ export default function SessionDetailPage() {
196
208
  <Card>
197
209
  <CardContent className="flex items-center gap-3 p-6 text-sm text-destructive">
198
210
  <IconExclamationCircle className="h-5 w-5" />
199
- {t("sessions.loadFailed", { message: error.message })}
211
+ {t("sessions.loadFailed", {
212
+ message: error instanceof Error ? error.message : String(error),
213
+ })}
200
214
  </CardContent>
201
215
  </Card>
202
216
  ) : isLoading ? (
@@ -268,7 +282,7 @@ function AskSessionPopover({
268
282
  function ReplayWorkbench({
269
283
  response,
270
284
  }: {
271
- response: SessionReplayEventsResponse;
285
+ response: SessionReplayPlaybackResponse;
272
286
  }) {
273
287
  const events = useReplayEvents(response);
274
288
  const markers = useMemo(() => buildReplayMarkers(events), [events]);
@@ -318,7 +332,7 @@ function ReplayPlayer({
318
332
  }: {
319
333
  events: AnyReplayEvent[];
320
334
  markers: ReplayMarker[];
321
- response: SessionReplayEventsResponse;
335
+ response: SessionReplayPlaybackResponse;
322
336
  onTimeUpdate: (ms: number) => void;
323
337
  registerSeek: (seek: (ms: number, autoplay?: boolean) => void) => void;
324
338
  }) {
@@ -956,8 +970,143 @@ function DetailSkeleton() {
956
970
  );
957
971
  }
958
972
 
973
+ function useSessionReplayPlayback(recordingId: string) {
974
+ return useQuery({
975
+ queryKey: ["session-replay-playback", recordingId],
976
+ enabled: Boolean(recordingId),
977
+ staleTime: 30_000,
978
+ gcTime: 60_000,
979
+ queryFn: () => fetchSessionReplayPlayback(recordingId),
980
+ });
981
+ }
982
+
983
+ export async function fetchSessionReplayPlayback(
984
+ recordingId: string,
985
+ ): Promise<SessionReplayPlaybackResponse> {
986
+ const manifest = await fetchReplayManifest(recordingId);
987
+ const chunks = await fetchReplayChunks(manifest.chunks);
988
+ const unavailableChunks = chunks.filter((chunk) => chunk.unavailable).length;
989
+ const eventCount = chunks.reduce(
990
+ (sum, chunk) => sum + chunk.events.length,
991
+ 0,
992
+ );
993
+ return {
994
+ recording: manifest.recording,
995
+ chunks,
996
+ eventCount,
997
+ truncated: false,
998
+ unavailableChunks,
999
+ };
1000
+ }
1001
+
1002
+ async function fetchReplayManifest(
1003
+ recordingId: string,
1004
+ ): Promise<SessionReplayManifestResponse> {
1005
+ const response = await fetchReplayApi(
1006
+ `/api/session-replay/recordings/${encodeURIComponent(
1007
+ recordingId,
1008
+ )}/manifest`,
1009
+ );
1010
+ if (!response.ok) throw await replayFetchError(response);
1011
+ return (await response.json()) as SessionReplayManifestResponse;
1012
+ }
1013
+
1014
+ async function fetchReplayChunks(
1015
+ chunks: SessionReplayManifestResponse["chunks"],
1016
+ ): Promise<ReplayChunkEvents[]> {
1017
+ const results = new Array<ReplayChunkEvents>(chunks.length);
1018
+ let nextIndex = 0;
1019
+
1020
+ async function worker() {
1021
+ while (nextIndex < chunks.length) {
1022
+ const index = nextIndex;
1023
+ nextIndex += 1;
1024
+ results[index] = await fetchReplayChunk(chunks[index]);
1025
+ }
1026
+ }
1027
+
1028
+ await Promise.all(
1029
+ Array.from(
1030
+ { length: Math.min(REPLAY_CHUNK_FETCH_CONCURRENCY, chunks.length) },
1031
+ worker,
1032
+ ),
1033
+ );
1034
+ return results;
1035
+ }
1036
+
1037
+ async function fetchReplayChunk(
1038
+ chunk: SessionReplayManifestResponse["chunks"][number],
1039
+ ): Promise<ReplayChunkEvents> {
1040
+ const response = await fetchReplayApi(chunk.bytesPath);
1041
+ if (!response.ok) {
1042
+ const error = await replayFetchError(response);
1043
+ if (isUnavailableReplayChunk(response, error)) {
1044
+ return replayUnavailableChunk(chunk);
1045
+ }
1046
+ throw error;
1047
+ }
1048
+ const payload = await response.json();
1049
+ return {
1050
+ seq: chunk.seq,
1051
+ checksum: chunk.checksum,
1052
+ byteLength: chunk.byteLength,
1053
+ eventCount: chunk.eventCount,
1054
+ events: replayPayloadEvents(payload),
1055
+ };
1056
+ }
1057
+
1058
+ function isUnavailableReplayChunk(response: Response, error: Error): boolean {
1059
+ return (
1060
+ response.status === 404 &&
1061
+ error.message === REPLAY_CHUNK_UNAVAILABLE_MESSAGE
1062
+ );
1063
+ }
1064
+
1065
+ function replayUnavailableChunk(
1066
+ chunk: SessionReplayManifestResponse["chunks"][number],
1067
+ ): ReplayChunkEvents {
1068
+ return {
1069
+ seq: chunk.seq,
1070
+ checksum: chunk.checksum,
1071
+ byteLength: chunk.byteLength,
1072
+ eventCount: chunk.eventCount,
1073
+ events: [],
1074
+ unavailable: true,
1075
+ };
1076
+ }
1077
+
1078
+ async function fetchReplayApi(path: string): Promise<Response> {
1079
+ const token = await getIdToken();
1080
+ return fetch(appApiPath(path), {
1081
+ headers: {
1082
+ Accept: "application/json",
1083
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
1084
+ },
1085
+ });
1086
+ }
1087
+
1088
+ async function replayFetchError(response: Response): Promise<Error> {
1089
+ try {
1090
+ const payload = await response.json();
1091
+ if (isRecord(payload) && typeof payload.error === "string") {
1092
+ return new Error(payload.error);
1093
+ }
1094
+ } catch {
1095
+ // Fall through to the status text.
1096
+ }
1097
+ return new Error(response.statusText || `HTTP ${response.status}`);
1098
+ }
1099
+
1100
+ export function replayPayloadEvents(payload: unknown): unknown[] {
1101
+ if (Array.isArray(payload)) return payload;
1102
+ if (isRecord(payload) && Array.isArray(payload.events)) {
1103
+ return payload.events;
1104
+ }
1105
+ return payload ? [payload] : [];
1106
+ }
1107
+
959
1108
  function useReplayEvents(
960
- response: SessionReplayEventsResponse,
1109
+ response: SessionReplayPlaybackResponse,
961
1110
  ): AnyReplayEvent[] {
962
1111
  return useMemo(
963
1112
  () =>
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-07-01
4
+ ---
5
+
6
+ Session replays load large production recordings from scoped chunks instead of truncating playback.
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  OpenSourceBadge,
3
3
  PoweredByBadge,
4
+ LanguagePicker,
4
5
  StarfieldBackground,
5
6
  useT,
6
7
  } from "@agent-native/core/client";
@@ -53,13 +54,23 @@ function BookingPageShell({
53
54
  return (
54
55
  <div
55
56
  className={cn(
56
- "relative min-h-screen overflow-x-hidden bg-background p-4",
57
+ "relative min-h-screen bg-background dark:bg-black",
57
58
  className,
58
59
  )}
59
60
  >
60
61
  <StarfieldBackground className="fixed inset-0 opacity-25 dark:opacity-60" />
61
- <div className="pointer-events-none fixed inset-0 bg-[radial-gradient(ellipse_at_center,hsl(var(--background)/0.35)_0%,hsl(var(--background)/0.88)_72%)]" />
62
- <div className="relative z-10 flow-root">{children}</div>
62
+ <div className="pointer-events-none fixed inset-0 bg-[radial-gradient(ellipse_at_center,hsl(var(--background)/0.35)_0%,hsl(var(--background)/0.88)_72%)] dark:bg-[radial-gradient(ellipse_at_center,hsl(var(--background)/0.35)_0%,#000000_100%)]" />
63
+ <div className="fixed top-4 right-4 z-50 flex items-center gap-1">
64
+ <LanguagePicker variant="ghost-icon" />
65
+ <ThemeToggle />
66
+ </div>
67
+ <div className="fixed bottom-[21px] left-4 z-50 flex flex-col items-start gap-2 max-sm:static max-sm:mx-auto max-sm:mt-8">
68
+ <PoweredByBadge variant="plain" embedded />
69
+ <OpenSourceBadge embedded />
70
+ </div>
71
+ <div className="relative z-10 min-h-screen overflow-x-hidden p-4">
72
+ {children}
73
+ </div>
63
74
  </div>
64
75
  );
65
76
  }
@@ -281,9 +292,6 @@ export default function BookingPage() {
281
292
 
282
293
  return (
283
294
  <BookingPageShell className="pb-20">
284
- <div className="absolute top-4 right-4 z-20">
285
- <ThemeToggle />
286
- </div>
287
295
  <div className="mx-auto mt-[7.5vh] w-full max-w-lg">
288
296
  {/* Header */}
289
297
  <div className="mb-8 text-center">
@@ -509,8 +517,6 @@ export default function BookingPage() {
509
517
  )}
510
518
  </div>
511
519
  </div>
512
- <OpenSourceBadge />
513
- <PoweredByBadge />
514
520
  </BookingPageShell>
515
521
  );
516
522
  }
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: improved
3
+ date: 2026-07-01
4
+ ---
5
+
6
+ Meeting invite pages have cleaner branding, a black dark-mode backdrop, and a language picker.
@@ -23,6 +23,7 @@ import {
23
23
  stringifySpaceIds,
24
24
  } from "../server/lib/recordings.js";
25
25
  import { setResumableSession } from "../server/lib/resumable-session.js";
26
+ import { isStreamingUploadDisabled } from "../server/lib/streaming-upload-mode.js";
26
27
  import { createRecordingSchema } from "./lib/create-recording-schema.js";
27
28
  import { DEFAULT_RECORDING_TITLE } from "./lib/title-source.js";
28
29
 
@@ -86,7 +87,11 @@ export default defineAction({
86
87
  // init fails.
87
88
  let uploadMode: UploadMode = "buffered";
88
89
  const uploadProvider = await getActiveFileUploadProviderForRequest();
89
- if (args.requestStreaming && uploadProvider?.resumable) {
90
+ if (
91
+ args.requestStreaming &&
92
+ !isStreamingUploadDisabled() &&
93
+ uploadProvider?.resumable
94
+ ) {
90
95
  try {
91
96
  const recordingMimeType =
92
97
  args.mimeType?.split(";")[0]?.trim() || "video/webm";
@@ -103,7 +108,7 @@ export default defineAction({
103
108
  await setResumableSession(id, {
104
109
  providerId: uploadProvider.id,
105
110
  sessionId: session.sessionId,
106
- meta: session.meta,
111
+ meta: { ...session.meta, skipCompressionWait: true },
107
112
  bytesUploaded: 0,
108
113
  lastCommittedIndex: -1,
109
114
  });
@@ -39,6 +39,7 @@ import {
39
39
  deleteResumableSession,
40
40
  getResumableSession,
41
41
  } from "../server/lib/resumable-session.js";
42
+ import { isStreamingUploadDisabled } from "../server/lib/streaming-upload-mode.js";
42
43
  import {
43
44
  requiresConfiguredVideoStorage,
44
45
  STORAGE_SETUP_REQUIRED_REASON,
@@ -341,6 +342,11 @@ export default defineAction({
341
342
  // Resumable path: create-recording initialized a session and chunk.post.ts
342
343
  // forwarded all chunks to the provider. Complete the session to get the CDN URL.
343
344
  const resumableSession = await getResumableSession(id);
345
+ if (resumableSession && isStreamingUploadDisabled()) {
346
+ console.warn(
347
+ `[finalize] streaming uploads are disabled, but completing existing resumable session for in-flight recording: ${id}`,
348
+ );
349
+ }
344
350
  if (resumableSession) {
345
351
  debugLog("[finalize] resumable session found, completing upload", {
346
352
  id,
@@ -351,6 +357,9 @@ export default defineAction({
351
357
  if (!uploadProvider?.resumable) {
352
358
  throw new Error("No resumable upload provider configured");
353
359
  }
360
+ if (resumableSession.bytesUploaded <= 0) {
361
+ throw new Error("Recording upload contained no video bytes");
362
+ }
354
363
  const videoUrl = await uploadProvider.resumable.completeSession(
355
364
  {
356
365
  sessionId: resumableSession.sessionId,
@@ -359,6 +368,7 @@ export default defineAction({
359
368
  typeof resumableSession.meta.filename === "string"
360
369
  ? resumableSession.meta.filename
361
370
  : "",
371
+ { skipCompressionWait: true },
362
372
  );
363
373
  debugLog("[finalize] resumable upload completed", { id, videoUrl });
364
374
  const result = await markRecordingReady({
@@ -560,6 +570,7 @@ export default defineAction({
560
570
  filename: `${id}.${videoFormat}`,
561
571
  mimeType,
562
572
  ownerEmail,
573
+ skipCompressionWait: true,
563
574
  });
564
575
  } catch (err) {
565
576
  // Capture structured context so a "Builder.io upload failed (500)" can
@@ -233,10 +233,12 @@ export default defineAction({
233
233
  // `?password=<pw>` (legacy fallback) so old share pages keep
234
234
  // working during rollout. (audit 11 F-07)
235
235
  // Owners are skipped — the blob route bypasses the password gate
236
- // for them, so they don't need the token. Non-Loom provider URLs
237
- // (R2/S3/Builder) are left untouched; those are already signed.
236
+ // for them, so they don't need the token. Remote provider URLs are
237
+ // still proxied through same-origin media serving so CORS, range
238
+ // requests, and signed URL quirks match public share playback.
238
239
  const resolvedVideoUrl = resolvePlayerVideoUrl(rec, {
239
240
  addPasswordToken: access.role !== "owner",
241
+ proxyRemoteMedia: true,
240
242
  });
241
243
 
242
244
  return {
@@ -295,6 +295,7 @@ export default defineAction({
295
295
  filename: `${id}.mp4`,
296
296
  mimeType: media.mimeType,
297
297
  ownerEmail,
298
+ skipCompressionWait: true,
298
299
  });
299
300
 
300
301
  const recordingValues = buildRecordingValues(media.sizeBytes);
@@ -17,6 +17,18 @@ export type LoomVideoDownload = {
17
17
  sourceUrl: string;
18
18
  };
19
19
 
20
+ const LOOM_VIDEO_UNAVAILABLE_MESSAGE =
21
+ "Loom did not provide a downloadable MP4 for this video. Download the original from Loom and use Upload video in Clips.";
22
+
23
+ export class LoomVideoUnavailableError extends Error {
24
+ statusCode = 422;
25
+
26
+ constructor(message = LOOM_VIDEO_UNAVAILABLE_MESSAGE) {
27
+ super(message);
28
+ this.name = "LoomVideoUnavailableError";
29
+ }
30
+ }
31
+
20
32
  function formatBytes(bytes: number): string {
21
33
  if (bytes >= 1024 * 1024) {
22
34
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
@@ -129,6 +141,10 @@ async function fetchTranscodedVideoUrl({
129
141
  { maxRedirects: 2 },
130
142
  );
131
143
 
144
+ if (response.status === 204) {
145
+ throw new LoomVideoUnavailableError();
146
+ }
147
+
132
148
  if (!response.ok) {
133
149
  throw new Error(
134
150
  `Loom did not provide a downloadable MP4 (${response.status} ${response.statusText}). Make sure the link is public and allows playback.`,
@@ -153,11 +153,11 @@ export function Scrubber(props: ScrubberProps) {
153
153
  <div
154
154
  ref={barRef}
155
155
  data-player-scrubber-bar
156
- className="relative w-full h-1.5 bg-white/20 rounded-full cursor-pointer group/bar"
156
+ className="relative w-full h-1.5 bg-white/35 rounded-full cursor-pointer group/bar shadow-[0_0_0_1px_rgba(0,0,0,0.16)]"
157
157
  >
158
158
  {/* Filled portion */}
159
159
  <div
160
- className="absolute inset-y-0 left-0 bg-primary rounded-full"
160
+ className="absolute inset-y-0 left-0 bg-white rounded-full shadow-[0_0_10px_rgba(255,255,255,0.45)]"
161
161
  style={{ width: pct + "%" }}
162
162
  />
163
163
 
@@ -95,10 +95,11 @@ export function CountdownOverlay({
95
95
 
96
96
  <div
97
97
  key={remaining}
98
- className="flex h-44 w-44 items-center justify-center rounded-full text-[112px] font-bold leading-none text-white shadow-2xl duration-200 animate-in zoom-in-75 fade-in"
98
+ className="flex min-w-32 items-center justify-center text-[clamp(96px,22vmin,240px)] font-extrabold leading-none text-white duration-200 animate-in zoom-in-75 fade-in"
99
99
  style={{
100
- background:
101
- "radial-gradient(circle at 30% 30%, rgba(255,255,255,0.18), transparent 60%), hsl(var(--primary))",
100
+ fontVariantNumeric: "tabular-nums",
101
+ textShadow:
102
+ "1px 0 1px rgba(17,24,39,0.22), -1px 0 1px rgba(17,24,39,0.22), 0 1px 1px rgba(17,24,39,0.22), 0 -1px 1px rgba(17,24,39,0.22), 0 2px 4px rgba(0,0,0,0.48), 0 12px 34px rgba(0,0,0,0.36), 0 0 2px rgba(0,0,0,0.72)",
102
103
  }}
103
104
  >
104
105
  {remaining > 0 ? remaining : "Go"}
@@ -58,6 +58,8 @@ import {
58
58
  import {
59
59
  NO_CAMERA_DEVICE_ID,
60
60
  NO_MIC_DEVICE_ID,
61
+ normalizeDisplaySurfaceForRuntime,
62
+ supportsBrowserTabCapture,
61
63
  type DisplaySurface,
62
64
  type RecordingMode,
63
65
  } from "./recorder-engine";
@@ -157,14 +159,20 @@ export function PreRecordPanel({
157
159
  const t = useT();
158
160
  const fileInputRef = useRef<HTMLInputElement>(null);
159
161
  const loomInputRef = useRef<HTMLInputElement>(null);
162
+ const browserTabCaptureSupported = useMemo(
163
+ () => supportsBrowserTabCapture(),
164
+ [],
165
+ );
160
166
  // Saved selections from the last visit. A `?mode=`/`?surface=` deep link
161
167
  // (initialMode/initialDisplaySurface) still takes precedence over them.
162
168
  const savedPrefs = useMemo(() => loadRecorderPreferences(), []);
163
169
  const [mode, setMode] = useState<RecordingMode>(
164
170
  () => initialMode ?? savedPrefs.mode ?? "screen+camera",
165
171
  );
166
- const [displaySurface, setDisplaySurface] = useState<DisplaySurface>(
167
- () => initialDisplaySurface ?? savedPrefs.displaySurface ?? "window",
172
+ const [displaySurface, setDisplaySurface] = useState<DisplaySurface>(() =>
173
+ normalizeDisplaySurfaceForRuntime(
174
+ initialDisplaySurface ?? savedPrefs.displaySurface ?? "window",
175
+ ),
168
176
  );
169
177
  const [sourceOpen, setSourceOpen] = useState(false);
170
178
  const [deviceSettingsOpen, setDeviceSettingsOpen] = useState(false);
@@ -223,8 +231,8 @@ export function PreRecordPanel({
223
231
  [isMobile, modeOptions],
224
232
  );
225
233
 
226
- const surfaceOptions = useMemo<SurfaceOption[]>(
227
- () => [
234
+ const surfaceOptions = useMemo<SurfaceOption[]>(() => {
235
+ const options: SurfaceOption[] = [
228
236
  {
229
237
  value: "window",
230
238
  label: t("preRecord.surfaceWindow"),
@@ -243,9 +251,11 @@ export function PreRecordPanel({
243
251
  icon: IconDeviceScreen,
244
252
  sub: t("preRecord.surfaceScreenDescription"),
245
253
  },
246
- ],
247
- [t],
248
- );
254
+ ];
255
+ return browserTabCaptureSupported
256
+ ? options
257
+ : options.filter((option) => option.value !== "browser");
258
+ }, [browserTabCaptureSupported, t]);
249
259
 
250
260
  useEffect(() => {
251
261
  if (isMobile) {
@@ -258,9 +268,19 @@ export function PreRecordPanel({
258
268
  }, [initialMode, isMobile]);
259
269
 
260
270
  useEffect(() => {
261
- if (initialDisplaySurface) setDisplaySurface(initialDisplaySurface);
271
+ if (initialDisplaySurface) {
272
+ setDisplaySurface(
273
+ normalizeDisplaySurfaceForRuntime(initialDisplaySurface),
274
+ );
275
+ }
262
276
  }, [initialDisplaySurface]);
263
277
 
278
+ useEffect(() => {
279
+ if (displaySurface !== "browser" || browserTabCaptureSupported) return;
280
+ setDisplaySurface("window");
281
+ saveRecorderPreferences({ displaySurface: "window" });
282
+ }, [browserTabCaptureSupported, displaySurface]);
283
+
264
284
  const enumerateDevices = useCallback(async () => {
265
285
  try {
266
286
  if (!navigator.mediaDevices?.enumerateDevices) {
@@ -350,8 +370,9 @@ export function PreRecordPanel({
350
370
  saveRecorderPreferences({ mode: value });
351
371
  }, []);
352
372
  const chooseDisplaySurface = useCallback((value: DisplaySurface) => {
353
- setDisplaySurface(value);
354
- saveRecorderPreferences({ displaySurface: value });
373
+ const next = normalizeDisplaySurfaceForRuntime(value);
374
+ setDisplaySurface(next);
375
+ saveRecorderPreferences({ displaySurface: next });
355
376
  }, []);
356
377
  const chooseMic = useCallback((value: string) => {
357
378
  setMicId(value);
@@ -676,7 +697,12 @@ export function PreRecordPanel({
676
697
  </button>
677
698
  </CollapsibleTrigger>
678
699
  <CollapsibleContent>
679
- <div className="grid grid-cols-3 gap-2 px-6 pb-5">
700
+ <div
701
+ className={cn(
702
+ "grid gap-2 px-6 pb-5",
703
+ surfaceOptions.length === 2 ? "grid-cols-2" : "grid-cols-3",
704
+ )}
705
+ >
680
706
  {surfaceOptions.map((opt) => {
681
707
  const Icon = opt.icon;
682
708
  const active = opt.value === displaySurface;
@@ -913,7 +939,8 @@ export function PreRecordPanel({
913
939
  // to acquire a webcam stream.
914
940
  mode:
915
941
  mode === "screen+camera" && !needsCamera ? "screen" : mode,
916
- displaySurface,
942
+ displaySurface:
943
+ normalizeDisplaySurfaceForRuntime(displaySurface),
917
944
  micDeviceId: micId === "default" ? null : micId,
918
945
  cameraDeviceId:
919
946
  needsCamera && cameraId !== "default" ? cameraId : null,