@agent-native/core 0.163.2 → 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.
- package/corpus/templates/clips/actions/add-comment.ts +11 -2
- package/corpus/templates/clips/actions/delete-comment.ts +14 -1
- package/corpus/templates/clips/actions/get-recording-player-data.ts +5 -1
- package/corpus/templates/clips/actions/react-to-comment.ts +4 -1
- package/corpus/templates/clips/actions/react-to-recording.ts +4 -1
- package/corpus/templates/clips/actions/reply-to-comment.ts +15 -2
- package/corpus/templates/clips/actions/resolve-comment.ts +29 -3
- package/corpus/templates/clips/actions/update-comment.ts +13 -1
- package/corpus/templates/clips/app/components/player/player-controls.tsx +43 -1
- package/corpus/templates/clips/app/components/player/timestamped-comment-button.tsx +12 -3
- package/corpus/templates/clips/app/components/player/video-player.tsx +43 -0
- package/corpus/templates/clips/app/routes/r.$recordingId.tsx +81 -18
- package/corpus/templates/clips/app/routes/share.$shareId.tsx +164 -53
- package/corpus/templates/clips/server/routes/api/public-recording.get.ts +5 -1
- package/dist/client/EnvironmentBadge.d.ts +5 -0
- package/dist/client/EnvironmentBadge.js +125 -13
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/routes.d.ts +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/file-upload/actions/upload-image.d.ts +1 -1
- package/dist/notifications/routes.d.ts +3 -3
- package/dist/observability/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/secrets/routes.d.ts +9 -9
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
44
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
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 [
|
|
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(
|
|
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
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
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(() =>
|
|
1701
|
-
|
|
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}
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
type FormEvent,
|
|
35
35
|
type ReactNode,
|
|
36
36
|
} from "react";
|
|
37
|
+
import { createPortal } from "react-dom";
|
|
37
38
|
import {
|
|
38
39
|
type HeadersArgs,
|
|
39
40
|
type LoaderFunctionArgs,
|
|
@@ -59,7 +60,10 @@ import { RequestAccessDialog } from "@/components/player/request-access-dialog";
|
|
|
59
60
|
import { ShareRecordingPopover } from "@/components/player/share-dialog";
|
|
60
61
|
import { SignInPromptDialog } from "@/components/player/sign-in-prompt-dialog";
|
|
61
62
|
import { SignedOutShareActions } from "@/components/player/signed-out-share-actions";
|
|
62
|
-
import {
|
|
63
|
+
import {
|
|
64
|
+
TimestampedCommentBar,
|
|
65
|
+
TimestampedCommentButton,
|
|
66
|
+
} from "@/components/player/timestamped-comment-button";
|
|
63
67
|
import { TranscriptPanel } from "@/components/player/transcript-panel";
|
|
64
68
|
import {
|
|
65
69
|
VideoPlayer,
|
|
@@ -446,6 +450,10 @@ export default function ShareRoute() {
|
|
|
446
450
|
});
|
|
447
451
|
const [pwError, setPwError] = useState<string | null>(null);
|
|
448
452
|
const [currentMs, setCurrentMs] = useState(0);
|
|
453
|
+
const [commentOpen, setCommentOpen] = useState(false);
|
|
454
|
+
const [commentAtMs, setCommentAtMs] = useState(0);
|
|
455
|
+
const [commentDraft, setCommentDraft] = useState("");
|
|
456
|
+
const [isPlayerFullscreen, setIsPlayerFullscreen] = useState(false);
|
|
449
457
|
const { session, isLoading: sessionLoading } = useSession();
|
|
450
458
|
const requestAccess = useActionMutation<
|
|
451
459
|
{
|
|
@@ -664,7 +672,10 @@ export default function ShareRoute() {
|
|
|
664
672
|
viewerRole === "owner" ||
|
|
665
673
|
viewerRole === "admin" ||
|
|
666
674
|
viewerRole === "editor";
|
|
667
|
-
|
|
675
|
+
// Any signed-in viewer with access to the recording may comment or react —
|
|
676
|
+
// an anonymous viewer sees the same controls but triggers the sign-in
|
|
677
|
+
// prompt instead (see `requireSignIn` below).
|
|
678
|
+
const viewerCanComment = Boolean(session) && viewerRole != null;
|
|
668
679
|
const viewerIsOwner = Boolean(dataQ.data?.data?.viewer?.isOwner);
|
|
669
680
|
const canReshareLink =
|
|
670
681
|
(viewerRole === "viewer" || viewerRole === "commenter") &&
|
|
@@ -1214,7 +1225,7 @@ export default function ShareRoute() {
|
|
|
1214
1225
|
</header>
|
|
1215
1226
|
|
|
1216
1227
|
<div className="flex flex-col gap-4 overflow-hidden p-0 sm:p-4 lg:min-h-0 lg:flex-1">
|
|
1217
|
-
<div className="aspect-video w-full lg:min-h-0 lg:flex-1 lg:aspect-auto">
|
|
1228
|
+
<div className="relative aspect-video w-full lg:min-h-0 lg:flex-1 lg:aspect-auto">
|
|
1218
1229
|
<VideoPlayer
|
|
1219
1230
|
ref={playerRef}
|
|
1220
1231
|
onVideoElementChange={setTrackedVideoEl}
|
|
@@ -1239,8 +1250,88 @@ export default function ShareRoute() {
|
|
|
1239
1250
|
onCtaClick={() => tracking.reportCtaClick()}
|
|
1240
1251
|
onTimeUpdate={(ms) => setCurrentMs(ms)}
|
|
1241
1252
|
onCommentClick={() => setPanel("comments")}
|
|
1253
|
+
onFullscreenChange={setIsPlayerFullscreen}
|
|
1254
|
+
enableComments={recording.enableComments}
|
|
1255
|
+
onAddComment={() => {
|
|
1256
|
+
if (!session) {
|
|
1257
|
+
requireSignIn("comment");
|
|
1258
|
+
return;
|
|
1259
|
+
}
|
|
1260
|
+
const liveCt = isLoomEmbedBacked
|
|
1261
|
+
? null
|
|
1262
|
+
: playerRef.current?.video?.currentTime;
|
|
1263
|
+
const liveMs =
|
|
1264
|
+
typeof liveCt === "number" &&
|
|
1265
|
+
Number.isFinite(liveCt) &&
|
|
1266
|
+
liveCt >= 0 &&
|
|
1267
|
+
liveCt < 1e7
|
|
1268
|
+
? Math.floor(liveCt * 1000)
|
|
1269
|
+
: currentMs;
|
|
1270
|
+
setCommentAtMs(liveMs);
|
|
1271
|
+
setCommentOpen(true);
|
|
1272
|
+
}}
|
|
1273
|
+
enableReactions={recording.enableReactions}
|
|
1274
|
+
onReact={(emoji) => {
|
|
1275
|
+
if (!session) {
|
|
1276
|
+
requireSignIn("react");
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1279
|
+
tracking.reportReaction(emoji);
|
|
1280
|
+
const liveCt = isLoomEmbedBacked
|
|
1281
|
+
? null
|
|
1282
|
+
: playerRef.current?.video?.currentTime;
|
|
1283
|
+
const liveMs =
|
|
1284
|
+
typeof liveCt === "number" &&
|
|
1285
|
+
Number.isFinite(liveCt) &&
|
|
1286
|
+
liveCt >= 0 &&
|
|
1287
|
+
liveCt < 1e7
|
|
1288
|
+
? Math.floor(liveCt * 1000)
|
|
1289
|
+
: currentMs;
|
|
1290
|
+
fetch(
|
|
1291
|
+
agentNativePath("/_agent-native/actions/react-to-recording"),
|
|
1292
|
+
{
|
|
1293
|
+
method: "POST",
|
|
1294
|
+
headers: { "Content-Type": "application/json" },
|
|
1295
|
+
body: JSON.stringify({
|
|
1296
|
+
recordingId: recording.id,
|
|
1297
|
+
emoji,
|
|
1298
|
+
videoTimestampMs: liveMs,
|
|
1299
|
+
}),
|
|
1300
|
+
},
|
|
1301
|
+
)
|
|
1302
|
+
.then((res) => {
|
|
1303
|
+
if (!res.ok) throw new Error(`react failed: ${res.status}`);
|
|
1304
|
+
return dataQ.refetch();
|
|
1305
|
+
})
|
|
1306
|
+
.catch((err) => console.warn("[clips] react failed", err));
|
|
1307
|
+
}}
|
|
1242
1308
|
className="h-full w-full rounded-none sm:rounded-xl"
|
|
1243
1309
|
/>
|
|
1310
|
+
{commentOpen && viewerCanComment
|
|
1311
|
+
? (() => {
|
|
1312
|
+
const composer = (
|
|
1313
|
+
<TimestampedCommentBar
|
|
1314
|
+
recordingId={recording.id}
|
|
1315
|
+
atMs={commentAtMs}
|
|
1316
|
+
draft={commentDraft}
|
|
1317
|
+
onDraftChange={setCommentDraft}
|
|
1318
|
+
onClose={() => setCommentOpen(false)}
|
|
1319
|
+
onAdded={() => {
|
|
1320
|
+
setPanel("comments");
|
|
1321
|
+
void dataQ.refetch();
|
|
1322
|
+
}}
|
|
1323
|
+
/>
|
|
1324
|
+
);
|
|
1325
|
+
// The Fullscreen API only paints the player's own element,
|
|
1326
|
+
// so portal the composer there instead of exiting
|
|
1327
|
+
// fullscreen when it's open.
|
|
1328
|
+
const fullscreenContainer =
|
|
1329
|
+
isPlayerFullscreen && playerRef.current?.container;
|
|
1330
|
+
return fullscreenContainer
|
|
1331
|
+
? createPortal(composer, fullscreenContainer)
|
|
1332
|
+
: composer;
|
|
1333
|
+
})()
|
|
1334
|
+
: null}
|
|
1244
1335
|
</div>
|
|
1245
1336
|
|
|
1246
1337
|
<div className="flex shrink-0 flex-col gap-3 px-4 pb-4 sm:flex-row sm:items-start sm:px-0 sm:pb-0">
|
|
@@ -1252,56 +1343,76 @@ export default function ShareRoute() {
|
|
|
1252
1343
|
) : null}
|
|
1253
1344
|
</div>
|
|
1254
1345
|
<div className="flex max-w-full flex-col items-stretch gap-2 sm:items-end">
|
|
1255
|
-
<
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1346
|
+
<div className="flex flex-wrap items-center gap-2 sm:justify-end">
|
|
1347
|
+
{recording.enableComments ? (
|
|
1348
|
+
<TimestampedCommentButton
|
|
1349
|
+
enableComments={recording.enableComments}
|
|
1350
|
+
canComment
|
|
1351
|
+
className="shrink-0"
|
|
1352
|
+
onOpen={() => {
|
|
1353
|
+
if (!session) {
|
|
1354
|
+
requireSignIn("comment");
|
|
1355
|
+
return;
|
|
1356
|
+
}
|
|
1357
|
+
const liveCt = isLoomEmbedBacked
|
|
1358
|
+
? null
|
|
1359
|
+
: playerRef.current?.video?.currentTime;
|
|
1360
|
+
const liveMs =
|
|
1361
|
+
typeof liveCt === "number" &&
|
|
1362
|
+
Number.isFinite(liveCt) &&
|
|
1363
|
+
liveCt >= 0 &&
|
|
1364
|
+
liveCt < 1e7
|
|
1365
|
+
? Math.floor(liveCt * 1000)
|
|
1366
|
+
: currentMs;
|
|
1367
|
+
setCommentAtMs(liveMs);
|
|
1368
|
+
setCommentOpen(true);
|
|
1369
|
+
}}
|
|
1370
|
+
/>
|
|
1371
|
+
) : null}
|
|
1372
|
+
{recording.enableReactions ? (
|
|
1373
|
+
<ReactionsTray
|
|
1374
|
+
onReact={(emoji) => {
|
|
1375
|
+
if (!session) {
|
|
1376
|
+
requireSignIn("react");
|
|
1377
|
+
return;
|
|
1378
|
+
}
|
|
1379
|
+
tracking.reportReaction(emoji);
|
|
1380
|
+
const liveCt = isLoomEmbedBacked
|
|
1381
|
+
? null
|
|
1382
|
+
: playerRef.current?.video?.currentTime;
|
|
1383
|
+
const liveMs =
|
|
1384
|
+
typeof liveCt === "number" &&
|
|
1385
|
+
Number.isFinite(liveCt) &&
|
|
1386
|
+
liveCt >= 0 &&
|
|
1387
|
+
liveCt < 1e7
|
|
1388
|
+
? Math.floor(liveCt * 1000)
|
|
1389
|
+
: currentMs;
|
|
1390
|
+
fetch(
|
|
1391
|
+
agentNativePath(
|
|
1392
|
+
"/_agent-native/actions/react-to-recording",
|
|
1393
|
+
),
|
|
1394
|
+
{
|
|
1395
|
+
method: "POST",
|
|
1396
|
+
headers: { "Content-Type": "application/json" },
|
|
1397
|
+
body: JSON.stringify({
|
|
1398
|
+
recordingId: recording.id,
|
|
1399
|
+
emoji,
|
|
1400
|
+
videoTimestampMs: liveMs,
|
|
1401
|
+
}),
|
|
1402
|
+
},
|
|
1403
|
+
)
|
|
1404
|
+
.then((res) => {
|
|
1405
|
+
if (!res.ok)
|
|
1406
|
+
throw new Error(`react failed: ${res.status}`);
|
|
1407
|
+
return dataQ.refetch();
|
|
1408
|
+
})
|
|
1409
|
+
.catch((err) =>
|
|
1410
|
+
console.warn("[clips] react failed", err),
|
|
1411
|
+
);
|
|
1412
|
+
}}
|
|
1413
|
+
/>
|
|
1414
|
+
) : null}
|
|
1415
|
+
</div>
|
|
1305
1416
|
{viewerCanEdit && canDownloadRecording ? (
|
|
1306
1417
|
<Button
|
|
1307
1418
|
variant="outline"
|
|
@@ -268,9 +268,13 @@ export default defineEventHandler(async (event) => {
|
|
|
268
268
|
return { error: "Not found" };
|
|
269
269
|
}
|
|
270
270
|
|
|
271
|
+
// Any signed-in viewer with access to the recording may comment or react —
|
|
272
|
+
// this covers an explicit share at any role (not just "commenter"), org
|
|
273
|
+
// membership, and a public link, which grants a viewer role that
|
|
274
|
+
// `resolveAccess` may not surface without an explicit share row.
|
|
271
275
|
const viewerCanComment = Boolean(
|
|
272
276
|
session?.email &&
|
|
273
|
-
(rec.visibility === "public" ||
|
|
277
|
+
(rec.visibility === "public" || viewerAccess || viewerIsOrgMember),
|
|
274
278
|
);
|
|
275
279
|
|
|
276
280
|
// Expiry check
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import type { AgentNativeDeploymentEnvironment, AgentNativeConfig } from "../config.js";
|
|
2
|
+
export declare const BETA_OPT_OUT_QUERY_PARAM = "agentNativeBetaOptOut";
|
|
3
|
+
export declare const BETA_OPT_OUT_STORAGE_KEY = "agent-native:beta-opt-out-until";
|
|
4
|
+
export declare const BETA_OPT_OUT_DURATION_MS: number;
|
|
2
5
|
export interface EnvironmentBadgeTargets {
|
|
3
6
|
betaHost: string;
|
|
4
7
|
productionHost: string;
|
|
@@ -7,6 +10,8 @@ export declare function isBuilderIoEmployee(email: string | null | undefined): b
|
|
|
7
10
|
export declare function resolveEnvironmentTargets(hostname: string | undefined): EnvironmentBadgeTargets | null;
|
|
8
11
|
export declare function resolveEnvironmentChannel(config: AgentNativeConfig, hostname: string | undefined): Extract<AgentNativeDeploymentEnvironment, "beta" | "production"> | null;
|
|
9
12
|
export declare function buildEnvironmentUrl(sourceHref: string, targetHost: string): string | null;
|
|
13
|
+
export declare function isBetaOptOutActive(value: string | number | null | undefined, now?: number): boolean;
|
|
14
|
+
export declare function buildEnvironmentOptOutUrl(sourceHref: string, targetHost: string, now?: number): string | null;
|
|
10
15
|
/**
|
|
11
16
|
* Small internal-only lane switcher for first-party hosted Agent-Native apps.
|
|
12
17
|
* It is mounted inside the authenticated AppProviders shell so public pages,
|
|
@@ -2,9 +2,32 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { Button } from "@agent-native/toolkit/ui/button";
|
|
3
3
|
import { Popover, PopoverContent, PopoverTrigger, } from "@agent-native/toolkit/ui/popover";
|
|
4
4
|
import { IconExternalLink, IconGitBranch } from "@tabler/icons-react";
|
|
5
|
-
import { useMemo } from "react";
|
|
5
|
+
import { useEffect, useMemo, useRef } from "react";
|
|
6
|
+
import { trackEvent } from "./analytics.js";
|
|
6
7
|
import { injectedAgentNativeConfig } from "./app-config.js";
|
|
7
8
|
import { useSession } from "./use-session.js";
|
|
9
|
+
export const BETA_OPT_OUT_QUERY_PARAM = "agentNativeBetaOptOut";
|
|
10
|
+
export const BETA_OPT_OUT_STORAGE_KEY = "agent-native:beta-opt-out-until";
|
|
11
|
+
export const BETA_OPT_OUT_DURATION_MS = 24 * 60 * 60 * 1000;
|
|
12
|
+
const ENVIRONMENT_BETA_HOSTS = {
|
|
13
|
+
"agent-workspace.builder.io": "beta.agent-workspace.builder.io",
|
|
14
|
+
"analytics.agent-native.com": "beta.analytics.agent-native.com",
|
|
15
|
+
"assets.agent-native.com": "beta.assets.agent-native.com",
|
|
16
|
+
"brain.agent-native.com": "beta.brain.agent-native.com",
|
|
17
|
+
"calendar.agent-native.com": "beta.calendar.agent-native.com",
|
|
18
|
+
"chat.agent-native.com": "beta.chat.agent-native.com",
|
|
19
|
+
"clips.agent-native.com": "beta.clips.agent-native.com",
|
|
20
|
+
"content.agent-native.com": "beta.content.agent-native.com",
|
|
21
|
+
"crm.agent-native.com": "beta.crm.agent-native.com",
|
|
22
|
+
"design.agent-native.com": "beta.design.agent-native.com",
|
|
23
|
+
"dispatch.agent-native.com": "beta.dispatch.agent-native.com",
|
|
24
|
+
"factory.agent-native.com": "beta.factory.agent-native.com",
|
|
25
|
+
"forms.agent-native.com": "beta.forms.agent-native.com",
|
|
26
|
+
"macros.agent-native.com": "beta.macros.agent-native.com",
|
|
27
|
+
"mail.agent-native.com": "beta.mail.agent-native.com",
|
|
28
|
+
"plan.agent-native.com": "beta.plan.agent-native.com",
|
|
29
|
+
"slides.agent-native.com": "beta.slides.agent-native.com",
|
|
30
|
+
};
|
|
8
31
|
export function isBuilderIoEmployee(email) {
|
|
9
32
|
return email?.trim().toLowerCase().endsWith("@builder.io") ?? false;
|
|
10
33
|
}
|
|
@@ -13,12 +36,11 @@ export function resolveEnvironmentTargets(hostname) {
|
|
|
13
36
|
if (!normalized)
|
|
14
37
|
return null;
|
|
15
38
|
const productionHost = normalized.replace(/^beta\./, "");
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
if (!isFirstPartyHost)
|
|
39
|
+
const betaHost = ENVIRONMENT_BETA_HOSTS[productionHost];
|
|
40
|
+
if (!betaHost)
|
|
19
41
|
return null;
|
|
20
42
|
return {
|
|
21
|
-
betaHost
|
|
43
|
+
betaHost,
|
|
22
44
|
productionHost,
|
|
23
45
|
};
|
|
24
46
|
}
|
|
@@ -47,6 +69,68 @@ export function buildEnvironmentUrl(sourceHref, targetHost) {
|
|
|
47
69
|
return null;
|
|
48
70
|
}
|
|
49
71
|
}
|
|
72
|
+
export function isBetaOptOutActive(value, now = Date.now()) {
|
|
73
|
+
const expiry = typeof value === "number" ? value : Number(value);
|
|
74
|
+
return Number.isFinite(expiry) && expiry > now;
|
|
75
|
+
}
|
|
76
|
+
export function buildEnvironmentOptOutUrl(sourceHref, targetHost, now = Date.now()) {
|
|
77
|
+
const targetHref = buildEnvironmentUrl(sourceHref, targetHost);
|
|
78
|
+
if (!targetHref)
|
|
79
|
+
return null;
|
|
80
|
+
try {
|
|
81
|
+
const target = new URL(targetHref);
|
|
82
|
+
target.searchParams.set(BETA_OPT_OUT_QUERY_PARAM, String(now + BETA_OPT_OUT_DURATION_MS));
|
|
83
|
+
return target.toString();
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// coercion-ok: buildEnvironmentUrl already validated the URL.
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function readBetaOptOutUntil(now = Date.now()) {
|
|
91
|
+
if (typeof window === "undefined")
|
|
92
|
+
return null;
|
|
93
|
+
try {
|
|
94
|
+
const value = window.localStorage.getItem(BETA_OPT_OUT_STORAGE_KEY);
|
|
95
|
+
if (isBetaOptOutActive(value, now))
|
|
96
|
+
return Number(value);
|
|
97
|
+
if (value !== null) {
|
|
98
|
+
window.localStorage.removeItem(BETA_OPT_OUT_STORAGE_KEY);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// coercion-ok: browser storage access is optional; the current URL remains authoritative.
|
|
103
|
+
// Private browsing can deny storage access. The switcher still works;
|
|
104
|
+
// the current navigation remains the explicit source of truth.
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
function consumeBetaOptOutQueryParam(sourceHref, now = Date.now()) {
|
|
109
|
+
let target;
|
|
110
|
+
try {
|
|
111
|
+
target = new URL(sourceHref);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// coercion-ok: the browser supplied an invalid location.
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
const rawExpiry = target.searchParams.get(BETA_OPT_OUT_QUERY_PARAM);
|
|
118
|
+
if (rawExpiry === null)
|
|
119
|
+
return false;
|
|
120
|
+
const active = isBetaOptOutActive(rawExpiry, now);
|
|
121
|
+
target.searchParams.delete(BETA_OPT_OUT_QUERY_PARAM);
|
|
122
|
+
try {
|
|
123
|
+
if (active) {
|
|
124
|
+
window.localStorage.setItem(BETA_OPT_OUT_STORAGE_KEY, String(Number(rawExpiry)));
|
|
125
|
+
}
|
|
126
|
+
window.history.replaceState(null, "", target.toString());
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
// coercion-ok: browser history/storage access is optional; the page must still load.
|
|
130
|
+
// A browser that denies storage/history access must not block the page.
|
|
131
|
+
}
|
|
132
|
+
return active;
|
|
133
|
+
}
|
|
50
134
|
function EnvironmentLink({ label, href }) {
|
|
51
135
|
return (_jsx(Button, { asChild: true, className: "w-full justify-start px-2", size: "sm", variant: "ghost", children: _jsxs("a", { href: href, children: [_jsx(IconExternalLink, { "aria-hidden": "true" }), label] }) }));
|
|
52
136
|
}
|
|
@@ -58,19 +142,47 @@ function EnvironmentLink({ label, href }) {
|
|
|
58
142
|
export function EnvironmentBadge() {
|
|
59
143
|
const { session, status } = useSession();
|
|
60
144
|
const config = useMemo(injectedAgentNativeConfig, []);
|
|
61
|
-
const
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
145
|
+
const didAutoRedirect = useRef(false);
|
|
146
|
+
const hostname = typeof window === "undefined" ? undefined : window.location.hostname;
|
|
147
|
+
const environment = resolveEnvironmentChannel(config, hostname);
|
|
148
|
+
const targets = resolveEnvironmentTargets(hostname);
|
|
149
|
+
const isEligible = typeof window !== "undefined" &&
|
|
150
|
+
window.parent === window &&
|
|
151
|
+
status === "authenticated" &&
|
|
152
|
+
isBuilderIoEmployee(session?.email) &&
|
|
153
|
+
!!environment &&
|
|
154
|
+
!!targets;
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
if (!isEligible ||
|
|
157
|
+
environment !== "production" ||
|
|
158
|
+
!targets ||
|
|
159
|
+
didAutoRedirect.current) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
didAutoRedirect.current = true;
|
|
163
|
+
if (readBetaOptOutUntil() !== null)
|
|
164
|
+
return;
|
|
165
|
+
if (consumeBetaOptOutQueryParam(window.location.href))
|
|
166
|
+
return;
|
|
167
|
+
const betaHref = buildEnvironmentUrl(window.location.href, targets.betaHost);
|
|
168
|
+
if (!betaHref || typeof window.location.replace !== "function")
|
|
169
|
+
return;
|
|
170
|
+
trackEvent("environment switched", {
|
|
171
|
+
from_environment: "production",
|
|
172
|
+
to_environment: "beta",
|
|
173
|
+
trigger: "automatic_redirect",
|
|
174
|
+
});
|
|
175
|
+
window.location.replace(betaHref);
|
|
176
|
+
}, [environment, isEligible, session?.email, status, targets?.betaHost]);
|
|
177
|
+
if (!isEligible ||
|
|
67
178
|
!environment ||
|
|
68
|
-
!targets
|
|
179
|
+
!targets ||
|
|
180
|
+
typeof window === "undefined") {
|
|
69
181
|
return null;
|
|
70
182
|
}
|
|
71
183
|
const currentHref = window.location.href;
|
|
72
184
|
const betaHref = buildEnvironmentUrl(currentHref, targets.betaHost);
|
|
73
|
-
const productionHref =
|
|
185
|
+
const productionHref = buildEnvironmentOptOutUrl(currentHref, targets.productionHost);
|
|
74
186
|
if (!betaHref || !productionHref)
|
|
75
187
|
return null;
|
|
76
188
|
const label = environment === "beta" ? "beta" : "prod";
|
|
@@ -62,11 +62,11 @@ export declare const postAwareness: import("h3").EventHandlerWithFetch<import("h
|
|
|
62
62
|
error: string;
|
|
63
63
|
states?: undefined;
|
|
64
64
|
} | {
|
|
65
|
+
error?: undefined;
|
|
65
66
|
states: {
|
|
66
67
|
clientId: number;
|
|
67
68
|
state: string;
|
|
68
69
|
}[];
|
|
69
|
-
error?: undefined;
|
|
70
70
|
}>>;
|
|
71
71
|
/**
|
|
72
72
|
* GET /_agent-native/collab/:docId/users
|
|
@@ -77,9 +77,9 @@ export declare const getActiveUsers: import("h3").EventHandlerWithFetch<import("
|
|
|
77
77
|
error: string;
|
|
78
78
|
users?: undefined;
|
|
79
79
|
} | {
|
|
80
|
+
error?: undefined;
|
|
80
81
|
users: {
|
|
81
82
|
clientId: number;
|
|
82
83
|
lastSeen: number;
|
|
83
84
|
}[];
|
|
84
|
-
error?: undefined;
|
|
85
85
|
}>>;
|
package/dist/collab/routes.d.ts
CHANGED
|
@@ -26,8 +26,8 @@ export declare const getCollabState: import("h3").EventHandlerWithFetch<import("
|
|
|
26
26
|
* Body: { update: string (base64), requestSource?: string }
|
|
27
27
|
*/
|
|
28
28
|
export declare const postCollabUpdate: import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
|
|
29
|
-
ok?: undefined;
|
|
30
29
|
error: string;
|
|
30
|
+
ok?: undefined;
|
|
31
31
|
} | {
|
|
32
32
|
error?: undefined;
|
|
33
33
|
ok: boolean;
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
* Body: { json: any, fieldName?: string, type?: "map"|"array", requestSource?: string }
|
|
14
14
|
*/
|
|
15
15
|
export declare const postCollabJson: import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
|
|
16
|
-
error: string;
|
|
17
16
|
ok?: undefined;
|
|
17
|
+
error: string;
|
|
18
18
|
} | {
|
|
19
19
|
error?: undefined;
|
|
20
20
|
ok: boolean;
|
|
@@ -17,11 +17,11 @@ declare const _default: import("../../action.js").ActionDefinition<{
|
|
|
17
17
|
id?: undefined;
|
|
18
18
|
provider?: undefined;
|
|
19
19
|
} | {
|
|
20
|
+
error?: undefined;
|
|
20
21
|
configured?: undefined;
|
|
21
22
|
connectPath?: undefined;
|
|
22
23
|
url: string;
|
|
23
24
|
id: string;
|
|
24
25
|
provider: string;
|
|
25
|
-
error?: undefined;
|
|
26
26
|
}>;
|
|
27
27
|
export default _default;
|
|
@@ -11,14 +11,14 @@
|
|
|
11
11
|
* DELETE /_agent-native/notifications/:id — delete
|
|
12
12
|
*/
|
|
13
13
|
export declare function createNotificationsHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<"" | import("./types.js").Notification[] | {
|
|
14
|
+
error?: undefined;
|
|
14
15
|
count: number;
|
|
15
16
|
updated?: undefined;
|
|
16
|
-
error?: undefined;
|
|
17
17
|
ok?: undefined;
|
|
18
18
|
} | {
|
|
19
|
+
error?: undefined;
|
|
19
20
|
count?: undefined;
|
|
20
21
|
updated: number;
|
|
21
|
-
error?: undefined;
|
|
22
22
|
ok?: undefined;
|
|
23
23
|
} | {
|
|
24
24
|
count?: undefined;
|
|
@@ -26,8 +26,8 @@ export declare function createNotificationsHandler(): import("h3").EventHandlerW
|
|
|
26
26
|
error: string;
|
|
27
27
|
ok?: undefined;
|
|
28
28
|
} | {
|
|
29
|
+
error?: undefined;
|
|
29
30
|
count?: undefined;
|
|
30
31
|
updated?: undefined;
|
|
31
|
-
error?: undefined;
|
|
32
32
|
ok: boolean;
|
|
33
33
|
}>>;
|
|
@@ -48,8 +48,8 @@ export declare function handleUpdateResource(event: any): Promise<import("./stor
|
|
|
48
48
|
}>;
|
|
49
49
|
/** DELETE /_agent-native/resources/:id — delete a resource */
|
|
50
50
|
export declare function handleDeleteResource(event: any): Promise<{
|
|
51
|
-
error: string;
|
|
52
51
|
ok?: undefined;
|
|
52
|
+
error: string;
|
|
53
53
|
} | {
|
|
54
54
|
error?: undefined;
|
|
55
55
|
ok: boolean;
|
package/dist/secrets/routes.d.ts
CHANGED
|
@@ -34,37 +34,37 @@ export declare function createListSecretsHandler(): import("h3").EventHandlerWit
|
|
|
34
34
|
/** POST /_agent-native/secrets/:key — write a secret. */
|
|
35
35
|
export declare function createWriteSecretHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
|
|
36
36
|
error: string;
|
|
37
|
-
status?: undefined;
|
|
38
37
|
ok?: undefined;
|
|
38
|
+
status?: undefined;
|
|
39
39
|
} | {
|
|
40
|
+
error?: undefined;
|
|
40
41
|
ok: boolean;
|
|
41
42
|
status: string;
|
|
42
|
-
error?: undefined;
|
|
43
43
|
} | {
|
|
44
|
+
ok?: undefined;
|
|
44
45
|
error: string;
|
|
45
46
|
removed?: undefined;
|
|
46
|
-
ok?: undefined;
|
|
47
47
|
} | {
|
|
48
|
+
error?: undefined;
|
|
48
49
|
ok: boolean;
|
|
49
50
|
removed: boolean;
|
|
50
|
-
error?: undefined;
|
|
51
51
|
}>>;
|
|
52
52
|
/**
|
|
53
53
|
* POST /_agent-native/secrets/:key/test — validate an optional candidate value
|
|
54
54
|
* or the current stored value without changing anything.
|
|
55
55
|
*/
|
|
56
56
|
export declare function createTestSecretHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
|
|
57
|
+
ok?: undefined;
|
|
57
58
|
error: string;
|
|
58
59
|
note?: undefined;
|
|
59
|
-
ok?: undefined;
|
|
60
60
|
} | {
|
|
61
|
+
error?: undefined;
|
|
61
62
|
ok: boolean;
|
|
62
63
|
note?: undefined;
|
|
63
|
-
error?: undefined;
|
|
64
64
|
} | {
|
|
65
|
+
error?: undefined;
|
|
65
66
|
ok: boolean;
|
|
66
67
|
note: string;
|
|
67
|
-
error?: undefined;
|
|
68
68
|
} | {
|
|
69
69
|
note?: undefined;
|
|
70
70
|
ok: boolean;
|
|
@@ -95,11 +95,11 @@ export interface AdHocSecretPayload {
|
|
|
95
95
|
export declare function createAdHocSecretHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<AdHocSecretPayload[] | {
|
|
96
96
|
error: string;
|
|
97
97
|
} | {
|
|
98
|
+
error?: undefined;
|
|
98
99
|
ok: boolean;
|
|
99
100
|
key: string;
|
|
100
|
-
error?: undefined;
|
|
101
101
|
} | {
|
|
102
|
+
error?: undefined;
|
|
102
103
|
ok: boolean;
|
|
103
104
|
removed: boolean;
|
|
104
|
-
error?: undefined;
|
|
105
105
|
}>>;
|
|
@@ -27,10 +27,10 @@ export declare function resolveAgentEngineApiKeyWriteTarget(event: H3Event, scop
|
|
|
27
27
|
export declare function createAgentEngineApiKeyHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
|
|
28
28
|
error: any;
|
|
29
29
|
} | {
|
|
30
|
+
error?: undefined;
|
|
30
31
|
ok: boolean;
|
|
31
32
|
key: string;
|
|
32
33
|
baseUrlKey?: string;
|
|
33
34
|
scope: AgentEngineApiKeyScope;
|
|
34
|
-
error?: undefined;
|
|
35
35
|
}>>;
|
|
36
36
|
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-native/core",
|
|
3
|
-
"version": "0.163.
|
|
3
|
+
"version": "0.163.3",
|
|
4
4
|
"description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
|
|
5
5
|
"homepage": "https://github.com/BuilderIO/agent-native#readme",
|
|
6
6
|
"bugs": {
|