@agent-native/core 0.163.1 → 0.163.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.
@@ -10,12 +10,13 @@
10
10
  import { defineAction } from "@agent-native/core";
11
11
  import { writeAppState } from "@agent-native/core/application-state";
12
12
  import { getRequestUserEmail } from "@agent-native/core/server/request-context";
13
- import { assertAccess } from "@agent-native/core/sharing";
13
+ import { assertAccess, ForbiddenError } from "@agent-native/core/sharing";
14
14
  import { eq } from "drizzle-orm";
15
15
  import { z } from "zod";
16
16
 
17
17
  import { getDb, schema } from "../server/db/index.js";
18
18
  import { notifyRecordingComment } from "../server/lib/activity-notifications.js";
19
+ import { isRecordingExpired } from "../server/lib/recording-page-access.js";
19
20
  import { nanoid } from "../server/lib/recordings.js";
20
21
 
21
22
  export default defineAction({
@@ -47,7 +48,15 @@ export default defineAction({
47
48
  .describe("Display name for the author (falls back to email local part)"),
48
49
  }),
49
50
  run: async (args) => {
50
- await assertAccess("recording", args.recordingId, "commenter");
51
+ // Commenting is open to any signed-in viewer with access to the
52
+ // recording, not just an explicitly-granted "commenter" role — the
53
+ // `authorEmail` check below is what actually requires an account.
54
+ const access = await assertAccess("recording", args.recordingId, "viewer");
55
+ if (
56
+ isRecordingExpired((access.resource as { expiresAt?: string }).expiresAt)
57
+ ) {
58
+ throw new ForbiddenError("Recording has expired");
59
+ }
51
60
 
52
61
  const authorEmail = getRequestUserEmail();
53
62
  if (!authorEmail) {
@@ -13,6 +13,7 @@ import { eq, or } from "drizzle-orm";
13
13
  import { z } from "zod";
14
14
 
15
15
  import { getDb, schema } from "../server/db/index.js";
16
+ import { isRecordingExpired } from "../server/lib/recording-page-access.js";
16
17
 
17
18
  export default defineAction({
18
19
  description:
@@ -32,7 +33,19 @@ export default defineAction({
32
33
  const userEmail = getRequestUserEmail();
33
34
  const isAuthor = !!userEmail && existing.authorEmail === userEmail;
34
35
 
35
- await assertAccess("recording", existing.recordingId, "commenter");
36
+ // Any signed-in viewer with access to the recording may delete their own
37
+ // comment, matching add-comment's top-level comment gate; a non-author
38
+ // still needs editor+ (checked below).
39
+ const access = await assertAccess(
40
+ "recording",
41
+ existing.recordingId,
42
+ "viewer",
43
+ );
44
+ if (
45
+ isRecordingExpired((access.resource as { expiresAt?: string }).expiresAt)
46
+ ) {
47
+ throw new ForbiddenError("Recording has expired");
48
+ }
36
49
 
37
50
  if (!isAuthor) {
38
51
  try {
@@ -155,7 +155,11 @@ export default defineAction({
155
155
  access.role === "owner" ||
156
156
  access.role === "admin" ||
157
157
  access.role === "editor";
158
- const canCommentRecording = canEditRecording || access.role === "commenter";
158
+ // Reaching this action already requires a signed-in session with at
159
+ // least viewer access to the recording (`resolveAccess` above), so any
160
+ // resolved role qualifies to comment/react — no separate "commenter"
161
+ // tier.
162
+ const canCommentRecording = true;
159
163
  // This action is on a 1-3s poll from the player, so every read here shares
160
164
  // one Promise.all instead of adding serial round-trips.
161
165
  const [
@@ -44,7 +44,10 @@ export default defineAction({
44
44
  if (!existing) throw new Error(`Comment not found: ${args.commentId}`);
45
45
 
46
46
  if (attempt === 0) {
47
- await assertAccess("recording", existing.recordingId, "commenter");
47
+ // Same floor as add-comment/react-to-recording: any signed-in
48
+ // viewer with access may react (the `viewerEmail` check above
49
+ // requires an account).
50
+ await assertAccess("recording", existing.recordingId, "viewer");
48
51
  }
49
52
 
50
53
  const previousJson = existing.emojiReactionsJson;
@@ -30,7 +30,10 @@ export default defineAction({
30
30
  viewerName: z.string().optional(),
31
31
  }),
32
32
  run: async (args) => {
33
- await assertAccess("recording", args.recordingId, "commenter");
33
+ // Reacting is open to any signed-in viewer with access to the
34
+ // recording, not just an explicitly-granted "commenter" role — the
35
+ // `viewerEmail` check below is what actually requires an account.
36
+ await assertAccess("recording", args.recordingId, "viewer");
34
37
 
35
38
  const viewerEmail = getRequestUserEmail();
36
39
  if (!viewerEmail) {
@@ -10,12 +10,13 @@
10
10
  import { defineAction } from "@agent-native/core";
11
11
  import { writeAppState } from "@agent-native/core/application-state";
12
12
  import { getRequestUserEmail } from "@agent-native/core/server/request-context";
13
- import { assertAccess } from "@agent-native/core/sharing";
13
+ import { assertAccess, ForbiddenError } from "@agent-native/core/sharing";
14
14
  import { eq } from "drizzle-orm";
15
15
  import { z } from "zod";
16
16
 
17
17
  import { getDb, schema } from "../server/db/index.js";
18
18
  import { notifyRecordingComment } from "../server/lib/activity-notifications.js";
19
+ import { isRecordingExpired } from "../server/lib/recording-page-access.js";
19
20
  import { nanoid } from "../server/lib/recordings.js";
20
21
 
21
22
  export default defineAction({
@@ -38,7 +39,19 @@ export default defineAction({
38
39
  .limit(1);
39
40
  if (!parent) throw new Error(`Comment not found: ${args.commentId}`);
40
41
 
41
- await assertAccess("recording", parent.recordingId, "commenter");
42
+ // Any signed-in viewer with access to the recording may reply, matching
43
+ // add-comment's top-level comment gate — there's no separate
44
+ // "commenter" tier to require.
45
+ const access = await assertAccess(
46
+ "recording",
47
+ parent.recordingId,
48
+ "viewer",
49
+ );
50
+ if (
51
+ isRecordingExpired((access.resource as { expiresAt?: string }).expiresAt)
52
+ ) {
53
+ throw new ForbiddenError("Recording has expired");
54
+ }
42
55
 
43
56
  const authorEmail = getRequestUserEmail();
44
57
  if (!authorEmail) {
@@ -9,11 +9,13 @@
9
9
  import { defineAction } from "@agent-native/core";
10
10
  import { writeAppState } from "@agent-native/core/application-state";
11
11
  import { getRequestUserEmail } from "@agent-native/core/server/request-context";
12
- import { assertAccess } from "@agent-native/core/sharing";
12
+ import { assertAccess, ForbiddenError } from "@agent-native/core/sharing";
13
13
  import { eq } from "drizzle-orm";
14
14
  import { z } from "zod";
15
15
 
16
16
  import { getDb, schema } from "../server/db/index.js";
17
+ import { isRecordingExpired } from "../server/lib/recording-page-access.js";
18
+ import { sameOwnerEmail } from "../server/lib/recordings.js";
17
19
 
18
20
  const cliBoolean = z.preprocess((value) => {
19
21
  if (value === "true") return true;
@@ -40,11 +42,35 @@ export default defineAction({
40
42
  .limit(1);
41
43
  if (!existing) throw new Error(`Comment not found: ${args.id}`);
42
44
 
43
- await assertAccess("recording", existing.recordingId, "commenter");
44
- if (!getRequestUserEmail()) {
45
+ const userEmail = getRequestUserEmail();
46
+ if (!userEmail) {
45
47
  throw new Error("Sign in required to resolve comments.");
46
48
  }
47
49
 
50
+ const access = await assertAccess(
51
+ "recording",
52
+ existing.recordingId,
53
+ "viewer",
54
+ );
55
+ if (
56
+ isRecordingExpired((access.resource as { expiresAt?: string }).expiresAt)
57
+ ) {
58
+ throw new ForbiddenError("Recording has expired");
59
+ }
60
+
61
+ if (!sameOwnerEmail(existing.authorEmail, userEmail)) {
62
+ try {
63
+ await assertAccess("recording", existing.recordingId, "editor");
64
+ } catch (err) {
65
+ if (err instanceof ForbiddenError) {
66
+ throw new ForbiddenError(
67
+ "Only the comment author or a recording editor can resolve this comment.",
68
+ );
69
+ }
70
+ throw err;
71
+ }
72
+ }
73
+
48
74
  const next = args.resolved ?? !existing.resolved;
49
75
  const now = new Date().toISOString();
50
76
 
@@ -13,6 +13,7 @@ import { and, eq } from "drizzle-orm";
13
13
  import { z } from "zod";
14
14
 
15
15
  import { getDb, schema } from "../server/db/index.js";
16
+ import { isRecordingExpired } from "../server/lib/recording-page-access.js";
16
17
  import { sameOwnerEmail } from "../server/lib/recordings.js";
17
18
 
18
19
  export default defineAction({
@@ -42,7 +43,18 @@ export default defineAction({
42
43
  .limit(1);
43
44
  if (!existing) throw new Error(`Comment not found: ${args.id}`);
44
45
 
45
- await assertAccess("recording", existing.recordingId, "commenter");
46
+ // Any signed-in viewer with access to the recording may edit their own
47
+ // comment, matching add-comment's top-level comment gate.
48
+ const access = await assertAccess(
49
+ "recording",
50
+ existing.recordingId,
51
+ "viewer",
52
+ );
53
+ if (
54
+ isRecordingExpired((access.resource as { expiresAt?: string }).expiresAt)
55
+ ) {
56
+ throw new ForbiddenError("Recording has expired");
57
+ }
46
58
 
47
59
  if (!sameOwnerEmail(existing.authorEmail, userEmail)) {
48
60
  throw new ForbiddenError(
@@ -6,6 +6,7 @@ import {
6
6
  IconVolume,
7
7
  IconVolumeOff,
8
8
  IconMaximize,
9
+ IconMessagePlus,
9
10
  IconPictureInPicture,
10
11
  IconSubtitles,
11
12
  IconRectangle,
@@ -33,6 +34,7 @@ import {
33
34
  import { PLAYBACK_SPEED_OPTIONS } from "@/lib/playback-speed";
34
35
  import { cn } from "@/lib/utils";
35
36
 
37
+ import { ReactionsTray } from "./reactions-tray";
36
38
  import { Scrubber, msToClock } from "./scrubber";
37
39
 
38
40
  export const SPEED_OPTIONS = PLAYBACK_SPEED_OPTIONS;
@@ -64,6 +66,16 @@ export interface PlayerControlsProps {
64
66
  onToggleFullscreen: () => void;
65
67
  onToggleTheater?: () => void;
66
68
  menuPortalContainer?: HTMLElement | null;
69
+ /**
70
+ * Surfaces the reaction tray and a comment-composer trigger inline in this
71
+ * bar (Loom-style), for contexts — namely fullscreen — where the caller's
72
+ * own reaction/comment row would otherwise be hidden.
73
+ */
74
+ showReactionsAndComment?: boolean;
75
+ enableReactions?: boolean;
76
+ onReact?: (emoji: string) => void;
77
+ enableComments?: boolean;
78
+ onAddComment?: () => void;
67
79
  }
68
80
 
69
81
  export function PlayerControls(props: PlayerControlsProps) {
@@ -94,6 +106,11 @@ export function PlayerControls(props: PlayerControlsProps) {
94
106
  onToggleFullscreen,
95
107
  onToggleTheater,
96
108
  menuPortalContainer,
109
+ showReactionsAndComment,
110
+ enableReactions,
111
+ onReact,
112
+ enableComments,
113
+ onAddComment,
97
114
  } = props;
98
115
 
99
116
  const [volumePopoverOpen, setVolumePopoverOpen] = useState(false);
@@ -116,7 +133,8 @@ export function PlayerControls(props: PlayerControlsProps) {
116
133
  reactions={reactions}
117
134
  />
118
135
 
119
- <div className="flex min-w-0 items-center gap-1.5 text-white">
136
+ {/* guard:allow-raw-color -- video controls overlay the dark player scrim itself, not themed app chrome, so text stays white regardless of light/dark mode */}
137
+ <div className="relative flex min-w-0 items-center gap-1.5 text-white">
120
138
  <IconBtn
121
139
  onClick={onPlayPause}
122
140
  tooltip={isPlaying ? "Pause (K)" : "Play (K)"}
@@ -280,6 +298,30 @@ export function PlayerControls(props: PlayerControlsProps) {
280
298
  className={cn("h-5 w-5", isFullscreen && "rotate-180")}
281
299
  />
282
300
  </IconBtn>
301
+
302
+ {showReactionsAndComment ? (
303
+ <div
304
+ data-player-ui
305
+ className="pointer-events-none absolute inset-x-0 top-1/2 flex -translate-y-1/2 items-center justify-center gap-2"
306
+ >
307
+ {enableReactions && onReact ? (
308
+ <div className="pointer-events-auto">
309
+ <ReactionsTray onReact={onReact} />
310
+ </div>
311
+ ) : null}
312
+
313
+ {enableComments && onAddComment ? (
314
+ <div className="pointer-events-auto">
315
+ <IconBtn
316
+ onClick={onAddComment}
317
+ tooltip={t("commentsPanel.commentButton")}
318
+ >
319
+ <IconMessagePlus className="h-5 w-5" />
320
+ </IconBtn>
321
+ </div>
322
+ ) : null}
323
+ </div>
324
+ ) : null}
283
325
  </div>
284
326
  </div>
285
327
  );
@@ -45,6 +45,8 @@ interface TimestampedCommentBarProps {
45
45
  onClose: () => void;
46
46
  onAdded?: () => void;
47
47
  className?: string;
48
+ draft?: string;
49
+ onDraftChange?: (value: string) => void;
48
50
  }
49
51
 
50
52
  /**
@@ -57,9 +59,13 @@ export function TimestampedCommentBar({
57
59
  onClose,
58
60
  onAdded,
59
61
  className,
62
+ draft: controlledDraft,
63
+ onDraftChange,
60
64
  }: TimestampedCommentBarProps) {
61
65
  const t = useT();
62
- const [draft, setDraft] = useState("");
66
+ const [uncontrolledDraft, setUncontrolledDraft] = useState("");
67
+ const draft = controlledDraft ?? uncontrolledDraft;
68
+ const setDraft = onDraftChange ?? setUncontrolledDraft;
63
69
  const textareaRef = useRef<HTMLTextAreaElement | null>(null);
64
70
 
65
71
  const addComment = useActionMutation("add-comment");
@@ -71,7 +77,7 @@ export function TimestampedCommentBar({
71
77
  const insertAtCursor = (text: string) => {
72
78
  const el = textareaRef.current;
73
79
  if (!el) {
74
- setDraft((d) => d + text);
80
+ setDraft(draft + text);
75
81
  return;
76
82
  }
77
83
  const start = el.selectionStart ?? draft.length;
@@ -101,7 +107,10 @@ export function TimestampedCommentBar({
101
107
  };
102
108
 
103
109
  return (
104
- <div className={cn("absolute inset-x-0 bottom-0 z-30 p-3", className)}>
110
+ <div
111
+ data-player-ui
112
+ className={cn("absolute inset-x-0 bottom-0 z-30 p-3", className)}
113
+ >
105
114
  <div className="rounded-xl border border-border bg-background/95 p-3 shadow-lg backdrop-blur">
106
115
  <Textarea
107
116
  ref={textareaRef}
@@ -153,6 +153,13 @@ type WebkitFullscreenVideo = HTMLVideoElement & {
153
153
 
154
154
  export interface VideoPlayerHandle {
155
155
  video: HTMLVideoElement | null;
156
+ /**
157
+ * The player's own root element — the element the browser Fullscreen API
158
+ * actually paints when fullscreen. Callers portal fullscreen-only overlays
159
+ * (e.g. a comment composer) into this so they stay visible instead of
160
+ * exiting fullscreen.
161
+ */
162
+ container: HTMLDivElement | null;
156
163
  play: () => Promise<void> | void;
157
164
  pause: () => void;
158
165
  seek: (ms: number) => void;
@@ -235,6 +242,25 @@ export interface VideoPlayerProps {
235
242
  onVideoElementChange?: (video: HTMLVideoElement | null) => void;
236
243
  /** Called when the viewer clicks the timestamped-comment overlay. */
237
244
  onCommentClick?: () => void;
245
+ /**
246
+ * Reaction tray + comment-composer trigger to surface while fullscreen.
247
+ * The real Fullscreen API only paints this component's own subtree, so
248
+ * the parent route's normal reaction/comment row (rendered as a sibling of
249
+ * `VideoPlayer`) disappears on entering fullscreen. When these are
250
+ * provided, an equivalent control row renders inside the player itself,
251
+ * visible only while `isFullscreen` is true.
252
+ */
253
+ enableReactions?: boolean;
254
+ onReact?: (emoji: string) => void;
255
+ enableComments?: boolean;
256
+ /**
257
+ * Opens the existing comment composer/panel. While fullscreen, the caller
258
+ * should portal that composer into `VideoPlayerHandle.container` (via the
259
+ * player ref) instead of exiting fullscreen, so it stays visible.
260
+ */
261
+ onAddComment?: () => void;
262
+ /** Fires whenever the player enters or exits fullscreen. */
263
+ onFullscreenChange?: (isFullscreen: boolean) => void;
238
264
  }
239
265
 
240
266
  export const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
@@ -275,6 +301,11 @@ export const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
275
301
  role,
276
302
  onVideoElementChange,
277
303
  onCommentClick,
304
+ enableReactions,
305
+ onReact,
306
+ enableComments,
307
+ onAddComment,
308
+ onFullscreenChange,
278
309
  } = props;
279
310
 
280
311
  const resolvedVideoSrc = useMemo(() => {
@@ -1032,6 +1063,9 @@ export const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
1032
1063
  get video() {
1033
1064
  return videoRef.current;
1034
1065
  },
1066
+ get container() {
1067
+ return containerRef.current;
1068
+ },
1035
1069
  play: requestPlay,
1036
1070
  pause: pauseVideo,
1037
1071
  seek: seekToVisibleMs,
@@ -1049,6 +1083,10 @@ export const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
1049
1083
  [applySpeed, pauseVideo, requestPlay, seekToVisibleMs],
1050
1084
  );
1051
1085
 
1086
+ useEffect(() => {
1087
+ onFullscreenChange?.(isFullscreen);
1088
+ }, [isFullscreen, onFullscreenChange]);
1089
+
1052
1090
  // Apply initial playbackRate and start position.
1053
1091
  useEffect(() => {
1054
1092
  const v = videoRef.current;
@@ -1976,6 +2014,11 @@ export const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
1976
2014
  onToggleFullscreen={() => void toggleFullscreenInternal()}
1977
2015
  onToggleTheater={onTheaterToggle}
1978
2016
  menuPortalContainer={fullscreenMenuContainer}
2017
+ showReactionsAndComment={isFullscreen}
2018
+ enableReactions={enableReactions}
2019
+ onReact={onReact}
2020
+ enableComments={enableComments}
2021
+ onAddComment={onAddComment}
1979
2022
  />
1980
2023
  </div>
1981
2024
  ) : null}
@@ -47,6 +47,7 @@ import {
47
47
  } from "@tabler/icons-react";
48
48
  import { useQuery } from "@tanstack/react-query";
49
49
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
50
+ import { createPortal } from "react-dom";
50
51
  import {
51
52
  Link,
52
53
  useParams,
@@ -254,6 +255,8 @@ export default function RecordingPage() {
254
255
  const [currentMs, setCurrentMs] = useState(0);
255
256
  const [commentOpen, setCommentOpen] = useState(false);
256
257
  const [commentAtMs, setCommentAtMs] = useState(0);
258
+ const [commentDraft, setCommentDraft] = useState("");
259
+ const [isPlayerFullscreen, setIsPlayerFullscreen] = useState(false);
257
260
  const isCompactLayout = useIsCompactRecordingLayout();
258
261
  // Resolve the playback position for reactions/comments. Native <video> exposes
259
262
  // a live `currentTime`; Loom embeds render in a cross-origin iframe with no
@@ -420,11 +423,10 @@ export default function RecordingPage() {
420
423
  const transcriptCleanup = playerDataQ.data?.transcript?.cleanup ?? null;
421
424
  const ctas = playerDataQ.data?.ctas ?? [];
422
425
  const canEdit = role === "owner" || role === "admin" || role === "editor";
423
- const canComment =
424
- role === "owner" ||
425
- role === "admin" ||
426
- role === "editor" ||
427
- role === "commenter";
426
+ // Reaching this page already requires a signed-in session with at least
427
+ // viewer access to the recording, so any resolved role qualifies to
428
+ // comment/react — no separate "commenter" tier.
429
+ const canComment = role != null;
428
430
  useEffect(() => {
429
431
  if (!canEdit && (panel === "insights" || panel === "settings")) {
430
432
  setPanel("comments");
@@ -1608,19 +1610,74 @@ export default function RecordingPage() {
1608
1610
  onCtaClick={() => tracking.reportCtaClick()}
1609
1611
  onTimeUpdate={(ms) => setCurrentMs(ms)}
1610
1612
  onCommentClick={openCommentsPanel}
1613
+ onFullscreenChange={setIsPlayerFullscreen}
1614
+ enableComments={recording.enableComments}
1615
+ onAddComment={() => {
1616
+ // The side panel `openCommentsPanel` opens is a sibling
1617
+ // outside the element the Fullscreen API paints, so it's
1618
+ // invisible while fullscreen -- portal the composer
1619
+ // there instead, same as the non-compact layout.
1620
+ if (isCompactLayout && !isPlayerFullscreen) {
1621
+ openCommentsPanel();
1622
+ return;
1623
+ }
1624
+ setCommentAtMs(resolvePlaybackMs());
1625
+ setCommentOpen(true);
1626
+ }}
1627
+ enableReactions={recording.enableReactions}
1628
+ onReact={(emoji) => {
1629
+ tracking.reportReaction(emoji);
1630
+ const liveMs = resolvePlaybackMs();
1631
+ fetch(
1632
+ agentNativePath(
1633
+ "/_agent-native/actions/react-to-recording",
1634
+ ),
1635
+ {
1636
+ method: "POST",
1637
+ headers: { "Content-Type": "application/json" },
1638
+ body: JSON.stringify({
1639
+ recordingId: recording.id,
1640
+ emoji,
1641
+ videoTimestampMs: liveMs,
1642
+ }),
1643
+ },
1644
+ )
1645
+ .then((res) => {
1646
+ if (!res.ok)
1647
+ throw new Error(`react failed: ${res.status}`);
1648
+ return playerDataQ.refetch();
1649
+ })
1650
+ .catch((err) =>
1651
+ console.warn("[clips] react failed", err),
1652
+ );
1653
+ }}
1611
1654
  className="h-full w-full rounded-none sm:rounded-xl"
1612
1655
  />
1613
- {commentOpen && canComment ? (
1614
- <TimestampedCommentBar
1615
- recordingId={recording.id}
1616
- atMs={commentAtMs}
1617
- onClose={() => setCommentOpen(false)}
1618
- onAdded={() => {
1619
- setPanel("comments");
1620
- void playerDataQ.refetch();
1621
- }}
1622
- />
1623
- ) : null}
1656
+ {commentOpen && canComment
1657
+ ? (() => {
1658
+ const composer = (
1659
+ <TimestampedCommentBar
1660
+ recordingId={recording.id}
1661
+ atMs={commentAtMs}
1662
+ draft={commentDraft}
1663
+ onDraftChange={setCommentDraft}
1664
+ onClose={() => setCommentOpen(false)}
1665
+ onAdded={() => {
1666
+ setPanel("comments");
1667
+ void playerDataQ.refetch();
1668
+ }}
1669
+ />
1670
+ );
1671
+ // The Fullscreen API only paints the player's own
1672
+ // element, so portal the composer there instead of
1673
+ // exiting fullscreen when it's open.
1674
+ const fullscreenContainer =
1675
+ isPlayerFullscreen && playerRef.current?.container;
1676
+ return fullscreenContainer
1677
+ ? createPortal(composer, fullscreenContainer)
1678
+ : composer;
1679
+ })()
1680
+ : null}
1624
1681
  </div>
1625
1682
 
1626
1683
  {/* Title + reactions row */}
@@ -1697,8 +1754,14 @@ export default function RecordingPage() {
1697
1754
  }),
1698
1755
  },
1699
1756
  )
1700
- .then(() => playerDataQ.refetch())
1701
- .catch(() => {});
1757
+ .then((res) => {
1758
+ if (!res.ok)
1759
+ throw new Error(`react failed: ${res.status}`);
1760
+ return playerDataQ.refetch();
1761
+ })
1762
+ .catch((err) =>
1763
+ console.warn("[clips] react failed", err),
1764
+ );
1702
1765
  }}
1703
1766
  />
1704
1767
  ) : null}