@agent-native/core 0.173.0 → 0.173.1-nightly-20260825123905

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 (78) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/.agents/skills/actions/SKILL.md +1 -0
  3. package/corpus/templates/assets/.agents/skills/actions/SKILL.md +1 -0
  4. package/corpus/templates/brain/.agents/skills/actions/SKILL.md +1 -0
  5. package/corpus/templates/calendar/.agents/skills/actions/SKILL.md +1 -0
  6. package/corpus/templates/chat/.agents/skills/actions/SKILL.md +1 -0
  7. package/corpus/templates/clips/.agents/skills/actions/SKILL.md +1 -0
  8. package/corpus/templates/clips/app/components/player/share-dialog.tsx +36 -2
  9. package/corpus/templates/clips/app/components/player/signed-out-share-actions.tsx +13 -3
  10. package/corpus/templates/clips/app/i18n/en-US.ts +2 -0
  11. package/corpus/templates/clips/app/lib/time-param.ts +40 -0
  12. package/corpus/templates/clips/app/routes/embed.$shareId.tsx +2 -35
  13. package/corpus/templates/clips/app/routes/r.$recordingId.tsx +58 -63
  14. package/corpus/templates/clips/app/routes/share.$shareId.tsx +16 -9
  15. package/corpus/templates/clips/changelog/2026-08-24-chrome-recording-recovers-a-dropped-mic-grant.md +6 -0
  16. package/corpus/templates/clips/chrome-extension/src/background.ts +138 -29
  17. package/corpus/templates/clips/chrome-extension/src/media-permission.ts +163 -0
  18. package/corpus/templates/clips/chrome-extension/src/offscreen.ts +43 -11
  19. package/corpus/templates/clips/chrome-extension/src/options.html +8 -6
  20. package/corpus/templates/clips/chrome-extension/src/permission.ts +4 -17
  21. package/corpus/templates/clips/chrome-extension/src/popup.ts +27 -39
  22. package/corpus/templates/clips/chrome-extension/src/styles.css +23 -0
  23. package/corpus/templates/clips/shared/share-attribution.ts +4 -2
  24. package/corpus/templates/clips/shared/share-dashboard-redirect.ts +1 -1
  25. package/corpus/templates/content/.agents/skills/actions/SKILL.md +1 -0
  26. package/corpus/templates/crm/.agents/skills/actions/SKILL.md +1 -0
  27. package/corpus/templates/design/.agents/skills/actions/SKILL.md +1 -0
  28. package/corpus/templates/design/.generated/bridge/editor-chrome.generated.ts +166 -109
  29. package/corpus/templates/design/app/components/design/CanvasContextMenu.tsx +82 -52
  30. package/corpus/templates/design/app/components/design/LayersPanel.tsx +17 -12
  31. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +242 -137
  32. package/corpus/templates/design/app/components/design/edit-panel/component-section.tsx +6 -1
  33. package/corpus/templates/design/app/components/design/edit-panel/position-layout-properties.tsx +11 -6
  34. package/corpus/templates/design/app/components/design/edit-panel/typography-properties.tsx +7 -2
  35. package/corpus/templates/design/app/components/design/editor/DesignBottomToolbar.tsx +4 -1
  36. package/corpus/templates/design/app/components/design/keyboard-shortcuts.ts +27 -4
  37. package/corpus/templates/design/app/hooks/use-shortcut-label.ts +21 -0
  38. package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +38 -16
  39. package/corpus/templates/design/app/pages/DesignEditor.tsx +17 -10
  40. package/corpus/templates/design/app/pages/design-editor/auto-layout-suggestion.ts +5 -3
  41. package/corpus/templates/design/app/pages/design-editor/commands/group-selection.ts +1 -1
  42. package/corpus/templates/design/app/pages/design-editor/commands/paste-to-replace.ts +28 -1
  43. package/corpus/templates/design/app/pages/design-editor/layout-operations.ts +33 -4
  44. package/corpus/templates/design/changelog/2026-08-24-adding-auto-layout-to-a-stack-of-full-width-rows-now-picks.md +6 -0
  45. package/corpus/templates/design/changelog/2026-08-24-dragging-an-element-no-longer-fades-it-or-changes-its-stacki.md +6 -0
  46. package/corpus/templates/design/changelog/2026-08-24-dropping-an-element-into-a-frame-that-already-has-content.md +6 -0
  47. package/corpus/templates/design/shared/code-layer.ts +35 -4
  48. package/corpus/templates/dispatch/.agents/skills/actions/SKILL.md +1 -0
  49. package/corpus/templates/factory/.agents/skills/actions/SKILL.md +1 -0
  50. package/corpus/templates/forms/.agents/skills/actions/SKILL.md +1 -0
  51. package/corpus/templates/mail/.agents/skills/actions/SKILL.md +1 -0
  52. package/corpus/templates/plan/.agents/skills/actions/SKILL.md +1 -0
  53. package/corpus/templates/slides/.agents/skills/actions/SKILL.md +1 -0
  54. package/corpus/templates/tasks/.agents/skills/actions/SKILL.md +1 -0
  55. package/dist/agent/engine/anthropic-engine.js +13 -3
  56. package/dist/agent/engine/types.d.ts +15 -0
  57. package/dist/collab/awareness.d.ts +2 -2
  58. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  59. package/dist/observability/posthog-ai.d.ts +25 -0
  60. package/dist/observability/posthog-ai.js +122 -0
  61. package/dist/observability/routes.d.ts +3 -3
  62. package/dist/observability/traces.js +30 -5
  63. package/dist/progress/routes.d.ts +1 -1
  64. package/dist/resources/handlers.d.ts +1 -1
  65. package/dist/secrets/routes.d.ts +6 -6
  66. package/dist/server/realtime-token.d.ts +1 -1
  67. package/dist/server/transcribe-voice.d.ts +1 -1
  68. package/dist/templates/chat/.agents/skills/actions/SKILL.md +1 -0
  69. package/dist/templates/default/.agents/skills/actions/SKILL.md +1 -0
  70. package/dist/templates/headless/.agents/skills/actions/SKILL.md +1 -0
  71. package/dist/templates/workspace-core/.agents/skills/actions/SKILL.md +1 -0
  72. package/dist/usage/store.d.ts +14 -2
  73. package/dist/usage/store.js +38 -10
  74. package/package.json +3 -3
  75. package/src/templates/chat/.agents/skills/actions/SKILL.md +1 -0
  76. package/src/templates/default/.agents/skills/actions/SKILL.md +1 -0
  77. package/src/templates/headless/.agents/skills/actions/SKILL.md +1 -0
  78. package/src/templates/workspace-core/.agents/skills/actions/SKILL.md +1 -0
package/corpus/README.md CHANGED
@@ -31,4 +31,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
31
31
 
32
32
  ## Generated Counts
33
33
 
34
- - template files: 8598
34
+ - template files: 8605
@@ -121,6 +121,7 @@ Everything else — CRUD, settings, search, list/detail reads, auth state, anyth
121
121
 
122
122
  - **Do** keep one action, one job; document a reusable action (when to use it, key args, return fields to preserve) in `AGENTS.md` once it's called from outside one narrow screen; promote workflow-heavy actions (provider-backed, cross-app, MCP/A2A, multi-step) into a skill.
123
123
  - **Do** use `fail()` for user-friendly errors and import primitives from `@agent-native/core`(`/action`) instead of redefining them; use the core `upload-image` action or `uploadFile()` for durable images/files — never base64 into SQL, markdown, or action results.
124
+ - **Do** signal failure by throwing (`fail()`), never by returning `{ error: ... }`. A returned envelope is a successful return everywhere the framework looks: the retry breakers (`MAX_IDENTICAL_TOOL_ERRORS`, `MAX_SAME_ERROR_ACROSS_ARGUMENTS`) never count it, the call is recorded `completedSideEffect: true` even though nothing was written, and `failed_tools` / `$ai_is_error` stay clean while the model keeps guessing. One production run spent 32% of its cost on three rejected writes that every dashboard reported as successes.
124
125
  - **Don't** re-export actions as REST — `/_agent-native/actions/:name` is already the REST surface; duplicating it under `/api/*` hides the operation from agents.
125
126
  - **Don't** reach for provider integrations, `outputSchema`, `authorize`, `needsApproval`, or `_agentImages` without checking `references/` first — each has sharp edges covered there, not here.
126
127
 
@@ -121,6 +121,7 @@ Everything else — CRUD, settings, search, list/detail reads, auth state, anyth
121
121
 
122
122
  - **Do** keep one action, one job; document a reusable action (when to use it, key args, return fields to preserve) in `AGENTS.md` once it's called from outside one narrow screen; promote workflow-heavy actions (provider-backed, cross-app, MCP/A2A, multi-step) into a skill.
123
123
  - **Do** use `fail()` for user-friendly errors and import primitives from `@agent-native/core`(`/action`) instead of redefining them; use the core `upload-image` action or `uploadFile()` for durable images/files — never base64 into SQL, markdown, or action results.
124
+ - **Do** signal failure by throwing (`fail()`), never by returning `{ error: ... }`. A returned envelope is a successful return everywhere the framework looks: the retry breakers (`MAX_IDENTICAL_TOOL_ERRORS`, `MAX_SAME_ERROR_ACROSS_ARGUMENTS`) never count it, the call is recorded `completedSideEffect: true` even though nothing was written, and `failed_tools` / `$ai_is_error` stay clean while the model keeps guessing. One production run spent 32% of its cost on three rejected writes that every dashboard reported as successes.
124
125
  - **Don't** re-export actions as REST — `/_agent-native/actions/:name` is already the REST surface; duplicating it under `/api/*` hides the operation from agents.
125
126
  - **Don't** reach for provider integrations, `outputSchema`, `authorize`, `needsApproval`, or `_agentImages` without checking `references/` first — each has sharp edges covered there, not here.
126
127
 
@@ -121,6 +121,7 @@ Everything else — CRUD, settings, search, list/detail reads, auth state, anyth
121
121
 
122
122
  - **Do** keep one action, one job; document a reusable action (when to use it, key args, return fields to preserve) in `AGENTS.md` once it's called from outside one narrow screen; promote workflow-heavy actions (provider-backed, cross-app, MCP/A2A, multi-step) into a skill.
123
123
  - **Do** use `fail()` for user-friendly errors and import primitives from `@agent-native/core`(`/action`) instead of redefining them; use the core `upload-image` action or `uploadFile()` for durable images/files — never base64 into SQL, markdown, or action results.
124
+ - **Do** signal failure by throwing (`fail()`), never by returning `{ error: ... }`. A returned envelope is a successful return everywhere the framework looks: the retry breakers (`MAX_IDENTICAL_TOOL_ERRORS`, `MAX_SAME_ERROR_ACROSS_ARGUMENTS`) never count it, the call is recorded `completedSideEffect: true` even though nothing was written, and `failed_tools` / `$ai_is_error` stay clean while the model keeps guessing. One production run spent 32% of its cost on three rejected writes that every dashboard reported as successes.
124
125
  - **Don't** re-export actions as REST — `/_agent-native/actions/:name` is already the REST surface; duplicating it under `/api/*` hides the operation from agents.
125
126
  - **Don't** reach for provider integrations, `outputSchema`, `authorize`, `needsApproval`, or `_agentImages` without checking `references/` first — each has sharp edges covered there, not here.
126
127
 
@@ -121,6 +121,7 @@ Everything else — CRUD, settings, search, list/detail reads, auth state, anyth
121
121
 
122
122
  - **Do** keep one action, one job; document a reusable action (when to use it, key args, return fields to preserve) in `AGENTS.md` once it's called from outside one narrow screen; promote workflow-heavy actions (provider-backed, cross-app, MCP/A2A, multi-step) into a skill.
123
123
  - **Do** use `fail()` for user-friendly errors and import primitives from `@agent-native/core`(`/action`) instead of redefining them; use the core `upload-image` action or `uploadFile()` for durable images/files — never base64 into SQL, markdown, or action results.
124
+ - **Do** signal failure by throwing (`fail()`), never by returning `{ error: ... }`. A returned envelope is a successful return everywhere the framework looks: the retry breakers (`MAX_IDENTICAL_TOOL_ERRORS`, `MAX_SAME_ERROR_ACROSS_ARGUMENTS`) never count it, the call is recorded `completedSideEffect: true` even though nothing was written, and `failed_tools` / `$ai_is_error` stay clean while the model keeps guessing. One production run spent 32% of its cost on three rejected writes that every dashboard reported as successes.
124
125
  - **Don't** re-export actions as REST — `/_agent-native/actions/:name` is already the REST surface; duplicating it under `/api/*` hides the operation from agents.
125
126
  - **Don't** reach for provider integrations, `outputSchema`, `authorize`, `needsApproval`, or `_agentImages` without checking `references/` first — each has sharp edges covered there, not here.
126
127
 
@@ -121,6 +121,7 @@ Everything else — CRUD, settings, search, list/detail reads, auth state, anyth
121
121
 
122
122
  - **Do** keep one action, one job; document a reusable action (when to use it, key args, return fields to preserve) in `AGENTS.md` once it's called from outside one narrow screen; promote workflow-heavy actions (provider-backed, cross-app, MCP/A2A, multi-step) into a skill.
123
123
  - **Do** use `fail()` for user-friendly errors and import primitives from `@agent-native/core`(`/action`) instead of redefining them; use the core `upload-image` action or `uploadFile()` for durable images/files — never base64 into SQL, markdown, or action results.
124
+ - **Do** signal failure by throwing (`fail()`), never by returning `{ error: ... }`. A returned envelope is a successful return everywhere the framework looks: the retry breakers (`MAX_IDENTICAL_TOOL_ERRORS`, `MAX_SAME_ERROR_ACROSS_ARGUMENTS`) never count it, the call is recorded `completedSideEffect: true` even though nothing was written, and `failed_tools` / `$ai_is_error` stay clean while the model keeps guessing. One production run spent 32% of its cost on three rejected writes that every dashboard reported as successes.
124
125
  - **Don't** re-export actions as REST — `/_agent-native/actions/:name` is already the REST surface; duplicating it under `/api/*` hides the operation from agents.
125
126
  - **Don't** reach for provider integrations, `outputSchema`, `authorize`, `needsApproval`, or `_agentImages` without checking `references/` first — each has sharp edges covered there, not here.
126
127
 
@@ -121,6 +121,7 @@ Everything else — CRUD, settings, search, list/detail reads, auth state, anyth
121
121
 
122
122
  - **Do** keep one action, one job; document a reusable action (when to use it, key args, return fields to preserve) in `AGENTS.md` once it's called from outside one narrow screen; promote workflow-heavy actions (provider-backed, cross-app, MCP/A2A, multi-step) into a skill.
123
123
  - **Do** use `fail()` for user-friendly errors and import primitives from `@agent-native/core`(`/action`) instead of redefining them; use the core `upload-image` action or `uploadFile()` for durable images/files — never base64 into SQL, markdown, or action results.
124
+ - **Do** signal failure by throwing (`fail()`), never by returning `{ error: ... }`. A returned envelope is a successful return everywhere the framework looks: the retry breakers (`MAX_IDENTICAL_TOOL_ERRORS`, `MAX_SAME_ERROR_ACROSS_ARGUMENTS`) never count it, the call is recorded `completedSideEffect: true` even though nothing was written, and `failed_tools` / `$ai_is_error` stay clean while the model keeps guessing. One production run spent 32% of its cost on three rejected writes that every dashboard reported as successes.
124
125
  - **Don't** re-export actions as REST — `/_agent-native/actions/:name` is already the REST surface; duplicating it under `/api/*` hides the operation from agents.
125
126
  - **Don't** reach for provider integrations, `outputSchema`, `authorize`, `needsApproval`, or `_agentImages` without checking `references/` first — each has sharp edges covered there, not here.
126
127
 
@@ -40,6 +40,7 @@ import {
40
40
  } from "@/components/ui/popover";
41
41
  import { Switch } from "@/components/ui/switch";
42
42
  import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
43
+ import { formatMs } from "@/lib/timestamp-mapping";
43
44
  import { cn } from "@/lib/utils";
44
45
 
45
46
  import { buildAgentApiUrls } from "../../../shared/agent-context";
@@ -70,6 +71,7 @@ export interface ShareRecordingPopoverProps {
70
71
  thumbnailUrl?: string | null;
71
72
  animatedThumbnailUrl?: string | null;
72
73
  isLoomRecording?: boolean;
74
+ currentMs?: number;
73
75
  hasPassword?: boolean;
74
76
  /**
75
77
  * Restricts the dialog to a bare copy-link control for viewers who can
@@ -108,6 +110,7 @@ export function ShareRecordingPopover({
108
110
  thumbnailUrl,
109
111
  animatedThumbnailUrl,
110
112
  isLoomRecording = false,
113
+ currentMs,
111
114
  hasPassword,
112
115
  viewerReshareOnly = false,
113
116
  children,
@@ -131,6 +134,7 @@ export function ShareRecordingPopover({
131
134
  thumbnailUrl={thumbnailUrl}
132
135
  animatedThumbnailUrl={animatedThumbnailUrl}
133
136
  isLoomRecording={isLoomRecording}
137
+ currentMs={currentMs}
134
138
  hasPassword={hasPassword}
135
139
  viewerReshareOnly={viewerReshareOnly}
136
140
  />
@@ -153,6 +157,7 @@ export function ShareRecordingDialog({
153
157
  thumbnailUrl,
154
158
  animatedThumbnailUrl,
155
159
  isLoomRecording = false,
160
+ currentMs,
156
161
  open,
157
162
  onOpenChange,
158
163
  hasPassword,
@@ -176,6 +181,7 @@ export function ShareRecordingDialog({
176
181
  thumbnailUrl={thumbnailUrl}
177
182
  animatedThumbnailUrl={animatedThumbnailUrl}
178
183
  isLoomRecording={isLoomRecording}
184
+ currentMs={currentMs}
179
185
  hasPassword={hasPassword}
180
186
  viewerReshareOnly={viewerReshareOnly}
181
187
  reserveCloseButton
@@ -194,6 +200,7 @@ function ShareRecordingContent({
194
200
  thumbnailUrl,
195
201
  animatedThumbnailUrl,
196
202
  isLoomRecording = false,
203
+ currentMs,
197
204
  reserveCloseButton = false,
198
205
  hasPassword,
199
206
  viewerReshareOnly = false,
@@ -206,6 +213,7 @@ function ShareRecordingContent({
206
213
  thumbnailUrl?: string | null;
207
214
  animatedThumbnailUrl?: string | null;
208
215
  isLoomRecording?: boolean;
216
+ currentMs?: number;
209
217
  reserveCloseButton?: boolean;
210
218
  hasPassword?: boolean;
211
219
  viewerReshareOnly?: boolean;
@@ -292,6 +300,7 @@ function ShareRecordingContent({
292
300
  thumbnailUrl={thumbnailUrl}
293
301
  animatedThumbnailUrl={animatedThumbnailUrl}
294
302
  isLoomRecording={isLoomRecording}
303
+ currentMs={currentMs}
295
304
  hasPassword={hasPassword}
296
305
  canViewShares={canViewShares}
297
306
  />
@@ -328,6 +337,7 @@ function LinkTab({
328
337
  thumbnailUrl,
329
338
  animatedThumbnailUrl,
330
339
  isLoomRecording: isLoomRecordingProp,
340
+ currentMs = 0,
331
341
  hasPassword,
332
342
  canViewShares,
333
343
  }: {
@@ -341,6 +351,7 @@ function LinkTab({
341
351
  thumbnailUrl?: string | null;
342
352
  animatedThumbnailUrl?: string | null;
343
353
  isLoomRecording?: boolean;
354
+ currentMs?: number;
344
355
  hasPassword?: boolean;
345
356
  canViewShares: boolean;
346
357
  }) {
@@ -352,6 +363,16 @@ function LinkTab({
352
363
  );
353
364
  const isPublic = visibility === "public";
354
365
  const sharesLoaded = visibility !== null;
366
+ const timestampMs = Number.isFinite(currentMs)
367
+ ? Math.max(0, Math.floor(currentMs! / 1000) * 1000)
368
+ : 0;
369
+ const [shareFromTimestamp, setShareFromTimestamp] = useState(false);
370
+ const linkUrl = useMemo(() => {
371
+ if (!shareFromTimestamp || timestampMs === 0) return shareUrl;
372
+ const url = new URL(shareUrl);
373
+ url.searchParams.set("at", String(timestampMs / 1000));
374
+ return url.toString();
375
+ }, [shareFromTimestamp, shareUrl, timestampMs]);
355
376
  const visibilityPending = isPending || sharesQuery.isLoading;
356
377
  const isLoomRecording = isLoomRecordingProp || isLoomEmbedUrl(videoUrl);
357
378
  const needsScopedAgentContext = !isPublic || hasPassword !== false;
@@ -504,10 +525,23 @@ function LinkTab({
504
525
  ? t("shareDialog.shareLink")
505
526
  : t("shareDialog.shareWithHumans")
506
527
  }
507
- value={shareUrl}
528
+ value={linkUrl}
508
529
  disabled={visibilityPending || !sharesLoaded}
509
530
  />
510
531
 
532
+ <div className="flex items-center justify-between gap-3">
533
+ <Label className="text-sm" htmlFor="share-from-timestamp">
534
+ {t("shareDialog.startAtTimestamp", {
535
+ time: formatMs(timestampMs),
536
+ })}
537
+ </Label>
538
+ <Switch
539
+ id="share-from-timestamp"
540
+ checked={shareFromTimestamp}
541
+ onCheckedChange={setShareFromTimestamp}
542
+ />
543
+ </div>
544
+
511
545
  {canViewShares ? (
512
546
  visibility ? (
513
547
  <GeneralAccessSelect
@@ -594,7 +628,7 @@ function LinkTab({
594
628
  }
595
629
  onMakePublic={() =>
596
630
  setResourceVisibility("public", {
597
- onSuccess: () => copyToClipboard(shareUrl),
631
+ onSuccess: () => copyToClipboard(linkUrl),
598
632
  })
599
633
  }
600
634
  />
@@ -7,15 +7,25 @@ import { Button } from "@/components/ui/button";
7
7
 
8
8
  export type SignedOutShareCta = "signin" | "try_clips";
9
9
 
10
- export function buildShareSignInHref(recordingId: string): string {
11
- return buildSignInReturnHref({ returnTo: `/share/${recordingId}` });
10
+ export function buildShareSignInHref(
11
+ recordingId: string,
12
+ startAt?: string | null,
13
+ ): string {
14
+ const params = new URLSearchParams();
15
+ if (startAt) params.set("at", startAt);
16
+ const query = params.toString();
17
+ return buildSignInReturnHref({
18
+ returnTo: `/share/${recordingId}${query ? `?${query}` : ""}`,
19
+ });
12
20
  }
13
21
 
14
22
  export function SignedOutShareActions({
15
23
  recordingId,
24
+ startAt,
16
25
  onCtaClick,
17
26
  }: {
18
27
  recordingId: string;
28
+ startAt?: string | null;
19
29
  onCtaClick?: (cta: SignedOutShareCta) => void;
20
30
  }) {
21
31
  const t = useT();
@@ -24,7 +34,7 @@ export function SignedOutShareActions({
24
34
  <>
25
35
  <Button variant="outline" size="sm" asChild>
26
36
  <a
27
- href={buildShareSignInHref(recordingId)}
37
+ href={buildShareSignInHref(recordingId, startAt)}
28
38
  className="gap-1.5"
29
39
  onClick={() => onCtaClick?.("signin")}
30
40
  >
@@ -144,6 +144,7 @@ const messages = {
144
144
  recordingNotFound: "Recording not found",
145
145
  noAccess: "You may not have access to this clip.",
146
146
  backToLibrary: "Back to library",
147
+ sharedWithYou: "Shared with you",
147
148
  storageStillDisconnected: "Storage still isn't connected",
148
149
  finishBuilderOrS3:
149
150
  "Finish the Builder.io popup or configure S3-compatible storage, then try again.",
@@ -515,6 +516,7 @@ const messages = {
515
516
  height: "Height",
516
517
  autoplay: "Autoplay",
517
518
  startAt: "Start at (seconds)",
519
+ startAtTimestamp: "Start at {{time}}",
518
520
  embedCode: "Embed code",
519
521
  },
520
522
  shareUi: {
@@ -0,0 +1,40 @@
1
+ export function resolveStartMs(
2
+ startMs: number,
3
+ durationMs?: number | null,
4
+ ): number {
5
+ if (!Number.isFinite(startMs) || startMs < 0) return 0;
6
+ if (
7
+ typeof durationMs === "number" &&
8
+ Number.isFinite(durationMs) &&
9
+ durationMs > 0 &&
10
+ startMs > durationMs
11
+ ) {
12
+ return 0;
13
+ }
14
+ return startMs;
15
+ }
16
+
17
+ export function parseTimeParam(raw: string | null): number {
18
+ if (!raw) return 0;
19
+ const value = raw.trim();
20
+ if (!value) return 0;
21
+
22
+ if (/^\d+(\.\d+)?$/.test(value)) {
23
+ return Math.floor(parseFloat(value) * 1000);
24
+ }
25
+
26
+ if (/^\d+:\d+(:\d+)?$/.test(value)) {
27
+ const parts = value.split(":").map((part) => parseInt(part, 10));
28
+ if (parts.length === 2) return (parts[0] * 60 + parts[1]) * 1000;
29
+ if (parts.length === 3) {
30
+ return (parts[0] * 3600 + parts[1] * 60 + parts[2]) * 1000;
31
+ }
32
+ }
33
+
34
+ const match = value.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/i);
35
+ if (!match) return 0;
36
+ const hours = parseInt(match[1] ?? "0", 10);
37
+ const minutes = parseInt(match[2] ?? "0", 10);
38
+ const seconds = parseInt(match[3] ?? "0", 10);
39
+ return (hours * 3600 + minutes * 60 + seconds) * 1000;
40
+ }
@@ -12,6 +12,7 @@ import {
12
12
  import { Spinner } from "@/components/ui/spinner";
13
13
  import { useViewTracking } from "@/hooks/use-view-tracking";
14
14
  import { parsePlaybackSpeed } from "@/lib/playback-speed";
15
+ import { parseTimeParam, resolveStartMs } from "@/lib/time-param";
15
16
 
16
17
  import { isLoomEmbedBackedRecording } from "../../shared/loom";
17
18
 
@@ -23,40 +24,6 @@ const STORAGE_KEY_PREFIX = "clips-share-pw-";
23
24
  const READY_MEDIA_SETTLE_POLL_MS = 20 * 1000;
24
25
  const READY_MEDIA_SETTLE_POLL_INTERVAL_MS = 1000;
25
26
 
26
- /**
27
- * Parse `t` URL param into ms (supports plain seconds or `1m20s` / `1h2m3s`).
28
- * "80" → 80_000
29
- * "1m20s" → 80_000
30
- * "1h2m3s" → 3_723_000
31
- * "1:20" → 80_000 (MM:SS)
32
- */
33
- function parseTimeParam(raw: string | null): number {
34
- if (!raw) return 0;
35
- const v = raw.trim();
36
- if (!v) return 0;
37
-
38
- // Plain seconds
39
- if (/^\d+(\.\d+)?$/.test(v)) return Math.floor(parseFloat(v) * 1000);
40
-
41
- // MM:SS or HH:MM:SS
42
- if (/^\d+:\d+(:\d+)?$/.test(v)) {
43
- const parts = v.split(":").map((n) => parseInt(n, 10));
44
- if (parts.length === 2) return (parts[0] * 60 + parts[1]) * 1000;
45
- if (parts.length === 3)
46
- return (parts[0] * 3600 + parts[1] * 60 + parts[2]) * 1000;
47
- }
48
-
49
- // 1h2m3s style
50
- const m = v.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/i);
51
- if (m) {
52
- const h = parseInt(m[1] ?? "0", 10);
53
- const mm = parseInt(m[2] ?? "0", 10);
54
- const s = parseInt(m[3] ?? "0", 10);
55
- return (h * 3600 + mm * 60 + s) * 1000;
56
- }
57
- return 0;
58
- }
59
-
60
27
  export default function EmbedRoute() {
61
28
  const t = useT();
62
29
  const { shareId } = useParams<{ shareId: string }>();
@@ -251,7 +218,7 @@ export default function EmbedRoute() {
251
218
  thumbnailUrl={recording.thumbnailUrl}
252
219
  defaultSpeed={parsePlaybackSpeed(recording.defaultSpeed) ?? 1.2}
253
220
  autoPlay={autoplay}
254
- startMs={startMs}
221
+ startMs={resolveStartMs(startMs, recording.durationMs)}
255
222
  comments={comments}
256
223
  chapters={chapters}
257
224
  transcriptSegments={transcriptSegments}
@@ -102,6 +102,7 @@ import enMessages from "@/i18n/en-US";
102
102
  import { parsePlaybackSpeed } from "@/lib/playback-speed";
103
103
  import { recordingShareUrl } from "@/lib/recording-link";
104
104
  import { isStorageSetupFailureReason } from "@/lib/storage-failures";
105
+ import { parseTimeParam, resolveStartMs } from "@/lib/time-param";
105
106
  import { cn } from "@/lib/utils";
106
107
 
107
108
  import { STALE_PENDING_TRANSCRIPT_REASON } from "../../shared/transcript-status";
@@ -270,31 +271,6 @@ export function BackButton({ onBack }: { onBack: () => void }) {
270
271
  );
271
272
  }
272
273
 
273
- function parseTimeParam(raw: string | null): number {
274
- if (!raw) return 0;
275
- const value = raw.trim();
276
- if (!value) return 0;
277
-
278
- if (/^\d+(\.\d+)?$/.test(value)) {
279
- return Math.floor(parseFloat(value) * 1000);
280
- }
281
-
282
- if (/^\d+:\d+(:\d+)?$/.test(value)) {
283
- const parts = value.split(":").map((part) => parseInt(part, 10));
284
- if (parts.length === 2) return (parts[0] * 60 + parts[1]) * 1000;
285
- if (parts.length === 3) {
286
- return (parts[0] * 3600 + parts[1] * 60 + parts[2]) * 1000;
287
- }
288
- }
289
-
290
- const match = value.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/i);
291
- if (!match) return 0;
292
- const hours = parseInt(match[1] ?? "0", 10);
293
- const minutes = parseInt(match[2] ?? "0", 10);
294
- const seconds = parseInt(match[3] ?? "0", 10);
295
- return (hours * 3600 + minutes * 60 + seconds) * 1000;
296
- }
297
-
298
274
  export default function RecordingPage() {
299
275
  const t = useT();
300
276
  useAutoTitleBridge();
@@ -302,7 +278,9 @@ export default function RecordingPage() {
302
278
  const { recordingId } = useParams<{ recordingId: string }>();
303
279
  const navigate = useNavigate();
304
280
  const [searchParams] = useSearchParams();
305
- const startMs = parseTimeParam(searchParams.get("t"));
281
+ const startMs = parseTimeParam(
282
+ searchParams.get("at") ?? searchParams.get("t"),
283
+ );
306
284
  const panelParam = searchParams.get("panel");
307
285
  const { session, isLoading: sessionLoading } = useSession();
308
286
  const playerRef = useRef<VideoPlayerHandle | null>(null);
@@ -310,19 +288,12 @@ export default function RecordingPage() {
310
288
  const [panel, setPanel] = useState<SidePanel>("comments");
311
289
  const [theaterMode, setTheaterMode] = useState(false);
312
290
  const [editing, setEditing] = useState(false);
313
- const [currentMs, setCurrentMs] = useState(0);
291
+ const [currentMs, setCurrentMs] = useState(startMs);
314
292
  const [commentOpen, setCommentOpen] = useState(false);
315
293
  const [commentAtMs, setCommentAtMs] = useState(0);
316
294
  const [commentDraft, setCommentDraft] = useState("");
317
295
  const [isPlayerFullscreen, setIsPlayerFullscreen] = useState(false);
318
296
  const isCompactLayout = useIsCompactRecordingLayout();
319
- // Resolve the playback position for reactions/comments. Native <video> exposes
320
- // a live `currentTime`; Loom embeds render in a cross-origin iframe with no
321
- // live time bridge, so we fall back to the last position the player reported
322
- // via onTimeUpdate (seek/initial start).
323
- const resolvePlaybackMs = useCallback(() => {
324
- return playerRef.current?.getCurrentOriginalMs() ?? currentMs;
325
- }, [currentMs]);
326
297
  // The compact layout stacks the panel below the video, so switching tabs
327
298
  // alone leaves the user looking at the player. Desktop always renders the
328
299
  // side aside, so nothing to scroll there.
@@ -458,6 +429,14 @@ export default function RecordingPage() {
458
429
  }, [recordingId, playerDataForbidden, navigate]);
459
430
 
460
431
  const recording = playerDataQ.data?.recording;
432
+ const playbackMs = resolveStartMs(currentMs, recording?.durationMs);
433
+ // Resolve the playback position for reactions/comments. Native <video> exposes
434
+ // a live `currentTime`; Loom embeds render in a cross-origin iframe with no
435
+ // live time bridge, so we fall back to the last position the player reported
436
+ // via onTimeUpdate (seek/initial start).
437
+ const resolvePlaybackMs = useCallback(() => {
438
+ return playerRef.current?.getCurrentOriginalMs() ?? playbackMs;
439
+ }, [playbackMs]);
461
440
  const verificationPending = recording?.verificationPending === true;
462
441
  const role = playerDataQ.data?.role as
463
442
  | "owner"
@@ -541,14 +520,14 @@ export default function RecordingPage() {
541
520
  {
542
521
  view: "recording",
543
522
  recordingId: recording.id,
544
- currentMs: Math.max(0, Math.round(currentMs)),
523
+ currentMs: Math.round(playbackMs),
545
524
  durationMs: recording.durationMs,
546
525
  panel,
547
526
  updatedAt: new Date(now).toISOString(),
548
527
  },
549
528
  { requestSource: browserTabId },
550
529
  ).catch(() => {});
551
- }, [browserTabId, currentMs, panel, recording?.durationMs, recording?.id]);
530
+ }, [browserTabId, panel, playbackMs, recording?.durationMs, recording?.id]);
552
531
  const appStateVersion = useChangeVersions(["app-state", "action"]);
553
532
  const generatedWorkflowQ = useQuery<GeneratedWorkflowState | null>({
554
533
  queryKey: [
@@ -615,6 +594,9 @@ export default function RecordingPage() {
615
594
  const viewerReshareOnly =
616
595
  (role === "viewer" || role === "commenter") &&
617
596
  (recording?.visibility === "public" || recording?.visibility === "org");
597
+ const isPrivateRecipient =
598
+ (role === "viewer" || role === "commenter") &&
599
+ recording?.visibility === "private";
618
600
  const shareVideoUrl =
619
601
  canDownloadRecording || isLoomEmbedBacked
620
602
  ? (recording?.videoUrl ?? null)
@@ -1064,16 +1046,22 @@ export default function RecordingPage() {
1064
1046
  ) : null}
1065
1047
  </p>
1066
1048
  </div>
1067
- <ShareRecordingPopover
1068
- recordingId={recording.id}
1069
- recordingTitle={recording.title}
1070
- initialVisibility={recording.visibility}
1071
- initialRole={role}
1072
- hasPassword={Boolean(recording.hasPassword)}
1073
- viewerReshareOnly={viewerReshareOnly}
1074
- >
1075
- <ClipsShareTrigger label={t("recordingPage.share")} />
1076
- </ShareRecordingPopover>
1049
+ {isPrivateRecipient ? (
1050
+ <span className="shrink-0 whitespace-nowrap text-sm font-medium text-muted-foreground">
1051
+ {t("recordingPage.sharedWithYou")}
1052
+ </span>
1053
+ ) : (
1054
+ <ShareRecordingPopover
1055
+ recordingId={recording.id}
1056
+ recordingTitle={recording.title}
1057
+ initialVisibility={recording.visibility}
1058
+ initialRole={role}
1059
+ hasPassword={Boolean(recording.hasPassword)}
1060
+ viewerReshareOnly={viewerReshareOnly}
1061
+ >
1062
+ <ClipsShareTrigger label={t("recordingPage.share")} />
1063
+ </ShareRecordingPopover>
1064
+ )}
1077
1065
  </header>
1078
1066
 
1079
1067
  <main className="mx-auto flex w-full max-w-6xl flex-1 flex-col gap-5 p-4 sm:p-6">
@@ -1283,7 +1271,7 @@ export default function RecordingPage() {
1283
1271
  segments={transcriptSegments}
1284
1272
  fullText={transcriptFullText}
1285
1273
  durationMs={recording.durationMs}
1286
- currentMs={currentMs}
1274
+ currentMs={playbackMs}
1287
1275
  onSeek={(ms) => playerRef.current?.seek(ms)}
1288
1276
  status={
1289
1277
  requestTranscript.isPending && transcriptStatus === "failed"
@@ -1322,7 +1310,7 @@ export default function RecordingPage() {
1322
1310
  <CommentsPanel
1323
1311
  recordingId={recording.id}
1324
1312
  comments={comments}
1325
- currentMs={currentMs}
1313
+ currentMs={playbackMs}
1326
1314
  currentUserEmail={session?.email}
1327
1315
  enableComments={recording.enableComments}
1328
1316
  canComment={canComment}
@@ -1602,20 +1590,27 @@ export default function RecordingPage() {
1602
1590
  ) : null}
1603
1591
 
1604
1592
  {!editing ? (
1605
- <ShareRecordingPopover
1606
- recordingId={recording.id}
1607
- recordingTitle={recording.title}
1608
- initialVisibility={recording.visibility}
1609
- initialRole={role}
1610
- videoUrl={shareVideoUrl}
1611
- thumbnailUrl={recording.thumbnailUrl}
1612
- animatedThumbnailUrl={recording.animatedThumbnailUrl}
1613
- isLoomRecording={isLoomEmbedBacked}
1614
- hasPassword={Boolean(recording.hasPassword)}
1615
- viewerReshareOnly={viewerReshareOnly}
1616
- >
1617
- <ClipsShareTrigger label={t("recordingPage.share")} />
1618
- </ShareRecordingPopover>
1593
+ isPrivateRecipient ? (
1594
+ <span className="shrink-0 whitespace-nowrap text-sm font-medium text-muted-foreground">
1595
+ {t("recordingPage.sharedWithYou")}
1596
+ </span>
1597
+ ) : (
1598
+ <ShareRecordingPopover
1599
+ recordingId={recording.id}
1600
+ recordingTitle={recording.title}
1601
+ initialVisibility={recording.visibility}
1602
+ initialRole={role}
1603
+ videoUrl={shareVideoUrl}
1604
+ thumbnailUrl={recording.thumbnailUrl}
1605
+ animatedThumbnailUrl={recording.animatedThumbnailUrl}
1606
+ isLoomRecording={isLoomEmbedBacked}
1607
+ currentMs={playbackMs}
1608
+ hasPassword={Boolean(recording.hasPassword)}
1609
+ viewerReshareOnly={viewerReshareOnly}
1610
+ >
1611
+ <ClipsShareTrigger label={t("recordingPage.share")} />
1612
+ </ShareRecordingPopover>
1613
+ )
1619
1614
  ) : null}
1620
1615
 
1621
1616
  {canDelete || canDownloadRecording ? (
@@ -1662,7 +1657,7 @@ export default function RecordingPage() {
1662
1657
  defaultSpeed={
1663
1658
  parsePlaybackSpeed(recording.defaultSpeed) ?? 1.2
1664
1659
  }
1665
- startMs={startMs}
1660
+ startMs={resolveStartMs(startMs, recording.durationMs)}
1666
1661
  comments={comments}
1667
1662
  chapters={chapters}
1668
1663
  reactions={reactions}