@agent-native/core 0.80.1 → 0.80.4

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 (59) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +65 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/action.ts +11 -0
  5. package/corpus/core/src/agent/production-agent.ts +3 -0
  6. package/corpus/core/src/cli/plan-local.ts +445 -108
  7. package/corpus/core/src/cli/skills.ts +14 -5
  8. package/corpus/core/src/mcp/build-server.ts +1 -1
  9. package/corpus/core/src/mcp/embed-app.ts +35 -1
  10. package/corpus/core/src/server/action-routes.ts +14 -0
  11. package/corpus/core/src/vite/client.ts +15 -1
  12. package/corpus/templates/clips/app/components/player/timestamped-comment-button.tsx +162 -0
  13. package/corpus/templates/clips/app/i18n/ar-SA.ts +4 -0
  14. package/corpus/templates/clips/app/i18n/de-DE.ts +4 -0
  15. package/corpus/templates/clips/app/i18n/en-US.ts +4 -0
  16. package/corpus/templates/clips/app/i18n/es-ES.ts +4 -0
  17. package/corpus/templates/clips/app/i18n/fr-FR.ts +4 -0
  18. package/corpus/templates/clips/app/i18n/hi-IN.ts +4 -0
  19. package/corpus/templates/clips/app/i18n/ja-JP.ts +4 -0
  20. package/corpus/templates/clips/app/i18n/ko-KR.ts +4 -0
  21. package/corpus/templates/clips/app/i18n/pt-BR.ts +4 -0
  22. package/corpus/templates/clips/app/i18n/zh-CN.ts +4 -0
  23. package/corpus/templates/clips/app/i18n/zh-TW.ts +4 -0
  24. package/corpus/templates/clips/app/routes/r.$recordingId.tsx +42 -18
  25. package/corpus/templates/plan/.agents/skills/visual-plan/references/local-files.md +14 -5
  26. package/corpus/templates/plan/.agents/skills/visual-recap/references/local-files.md +14 -5
  27. package/corpus/templates/plan/actions/validate-local-plan-source.ts +118 -0
  28. package/corpus/templates/plan/server/lib/plan-connector-catalog.ts +1 -0
  29. package/corpus/templates/plan/server/lib/public-action-paths.ts +1 -0
  30. package/dist/action.d.ts +8 -0
  31. package/dist/action.d.ts.map +1 -1
  32. package/dist/action.js +3 -0
  33. package/dist/action.js.map +1 -1
  34. package/dist/agent/production-agent.d.ts +3 -0
  35. package/dist/agent/production-agent.d.ts.map +1 -1
  36. package/dist/agent/production-agent.js.map +1 -1
  37. package/dist/cli/plan-local.d.ts +30 -0
  38. package/dist/cli/plan-local.d.ts.map +1 -1
  39. package/dist/cli/plan-local.js +281 -56
  40. package/dist/cli/plan-local.js.map +1 -1
  41. package/dist/cli/skills.d.ts +1 -1
  42. package/dist/cli/skills.d.ts.map +1 -1
  43. package/dist/cli/skills.js +14 -5
  44. package/dist/cli/skills.js.map +1 -1
  45. package/dist/collab/routes.d.ts +1 -1
  46. package/dist/mcp/build-server.js +1 -1
  47. package/dist/mcp/build-server.js.map +1 -1
  48. package/dist/mcp/embed-app.d.ts.map +1 -1
  49. package/dist/mcp/embed-app.js +35 -1
  50. package/dist/mcp/embed-app.js.map +1 -1
  51. package/dist/resources/handlers.d.ts +1 -1
  52. package/dist/server/action-routes.d.ts.map +1 -1
  53. package/dist/server/action-routes.js +14 -0
  54. package/dist/server/action-routes.js.map +1 -1
  55. package/dist/server/transcribe-voice.d.ts +1 -1
  56. package/dist/vite/client.d.ts.map +1 -1
  57. package/dist/vite/client.js +12 -1
  58. package/dist/vite/client.js.map +1 -1
  59. package/package.json +1 -1
@@ -1405,8 +1405,10 @@ The local-files contract:
1405
1405
  \`id\` and \`label\`; and \`Code\` / \`AnnotatedCode\` / \`Diff\` are whitespace-sensitive
1406
1406
  — encode multiline code as JSON string attributes such as \`code={"const x =\\n y"}\`
1407
1407
  (a static template literal is accepted only when it has no \`\${...}\`
1408
- interpolation). \`plan local check\` validates these required fields against the
1409
- renderer schema.
1408
+ interpolation). \`plan local check\` is a quick OFFLINE lint (a subset of the
1409
+ renderer schema), so a green \`check\` does not guarantee the plan renders;
1410
+ \`plan local verify\` is the authoritative validation against the real renderer
1411
+ schema.
1410
1412
  - **Write a local MDX folder.** Use \`plans/<slug>/\` to check the artifact into the
1411
1413
  repo, or a repo-ignored/temporary folder such as \`.agent-native/plans/<slug>/\`
1412
1414
  or \`/tmp/agent-native-plans/<slug>/\` when it should not be checked in. The
@@ -1431,9 +1433,16 @@ The local-files contract:
1431
1433
  running local Plan app.
1432
1434
  - **Headless verify.** Run
1433
1435
  \`npx @agent-native/core@latest plan local verify --dir <plan-dir> --kind <plan|recap>\`.
1434
- It starts the bridge, checks the private-network preflight and JSON payload,
1435
- prints diagnostics, and exits. If the browser hangs on "Loading plan", fetch the
1436
- \`bridgeUrl\` from the verify/serve JSON to read the concrete validation error.
1436
+ It starts the bridge, checks the private-network preflight and JSON payload, AND
1437
+ validates the content against the real renderer schema via the Plan app's
1438
+ \`validate-local-plan-source\` action. A non-\`ok\` result with
1439
+ \`validation.valid: false\` lists the renderer's exact schema-path issues (e.g.
1440
+ \`blocks[1].data.tabs[0]...\`); fix those before handing off. If \`validation.ran\`
1441
+ is \`false\`, the Plan app did not expose the validate endpoint (older/unreachable
1442
+ deploy) — point \`--app-url\` at a current Plan app (e.g. a local
1443
+ \`http://localhost:8096\`) for the authoritative check. If the browser hangs on
1444
+ "Loading plan", fetch the \`bridgeUrl\` from the verify/serve JSON to read the
1445
+ concrete validation error.
1437
1446
  - **Never call hosted tools for that plan/recap.** Do not call
1438
1447
  \`create-visual-plan\`, \`create-ui-plan\`, \`create-prototype-plan\`,
1439
1448
  \`create-plan-design\`, \`create-visual-recap\`, \`create-visual-questions\`,
@@ -750,7 +750,7 @@ function safeUiSegment(value: string | undefined, fallback: string): string {
750
750
 
751
751
  // ChatGPT and Claude cache MCP App resource HTML by `ui://` URI. Bump this
752
752
  // when the shared shell changes in a way that must invalidate host caches.
753
- const MCP_APP_RESOURCE_SHELL_VERSION = "shell-v53";
753
+ const MCP_APP_RESOURCE_SHELL_VERSION = "shell-v54";
754
754
 
755
755
  function legacyDefaultMcpAppUri(config: MCPConfig, actionName: string): string {
756
756
  const app = safeUiSegment(config.appId ?? config.name, "agent-native");
@@ -124,6 +124,7 @@ export function embedApp(
124
124
  let app = null;
125
125
  let appConnectPromise = null;
126
126
  let openAiBridge = null;
127
+ let lastOpenAiSyncSignature = null;
127
128
  let wrapperRequestId = 0;
128
129
  const wrapperRequests = new Map();
129
130
  let toolInput = {};
@@ -1533,11 +1534,19 @@ export function embedApp(
1533
1534
  setMessage("Ready to open.");
1534
1535
  return;
1535
1536
  }
1536
- setMessage("Loading app");
1537
1537
  try {
1538
1538
  const selfNavigate = shouldSelfNavigateToApp();
1539
1539
  if (startedFor === launchUrl) return;
1540
1540
  startedFor = launchUrl;
1541
+ // Only show the loading message when no frame is mounted yet. ChatGPT
1542
+ // fires openai:set_globals (and thus re-launch) constantly; calling
1543
+ // setMessage() unconditionally here wiped the just-mounted iframe out of
1544
+ // the stage on every redundant launch, so the app rendered and was
1545
+ // instantly blanked back to "Loading app" forever. renderFrame()/
1546
+ // transplant replace the stage themselves when they actually (re)mount.
1547
+ if (!appFrame) {
1548
+ setMessage("Loading app");
1549
+ }
1541
1550
  const embedUrl = withChatBridgeParam(launchUrl);
1542
1551
  if (selfNavigate && isEmbedStartUrl(embedUrl)) {
1543
1552
  if (shouldTransplantAppDocument()) {
@@ -1662,6 +1671,31 @@ export function embedApp(
1662
1671
  toolResultData = objectValue(data);
1663
1672
  openUrl = openLinkFrom(params, data);
1664
1673
  openStartUrl = embedStartUrlFrom(params, data);
1674
+ // ChatGPT/Codex fire openai:set_globals extremely often, and the listener
1675
+ // that calls this re-syncs on each one. Because this function calls
1676
+ // notifyHostHeight()/sendHostContext() (which the host reflects back as a
1677
+ // fresh set_globals), an unguarded sync becomes an infinite feedback storm
1678
+ // that starves the host until it shows its sad-face placeholder. Only do
1679
+ // the host round-trips + (re)launch when something we care about changed.
1680
+ // Deliberately EXCLUDE bridge.maxHeight: the host mutates it on every
1681
+ // set_globals as it echoes our height back, which would defeat the guard.
1682
+ let signature;
1683
+ try {
1684
+ signature = JSON.stringify([
1685
+ toolInput,
1686
+ openUrl,
1687
+ openStartUrl,
1688
+ bridge.displayMode,
1689
+ bridge.theme,
1690
+ bridge.locale
1691
+ ]);
1692
+ } catch {
1693
+ signature = null;
1694
+ }
1695
+ if (signature !== null && signature === lastOpenAiSyncSignature) {
1696
+ return true;
1697
+ }
1698
+ lastOpenAiSyncSignature = signature;
1665
1699
  updateTitle(data);
1666
1700
  updateOpenButton();
1667
1701
  updateDisplayButton();
@@ -284,6 +284,20 @@ export function mountActionRoutes(
284
284
  return runWithRequestContext(
285
285
  { userEmail, userName, orgId, timezone },
286
286
  async () => {
287
+ // Reject oversize bodies from Content-Length before parsing, so a
288
+ // public no-auth POST can't force parse work on a huge request.
289
+ if (typeof entry.maxBodyBytes === "number" && method !== "GET") {
290
+ const clRaw = getHeader(event, "content-length");
291
+ if (clRaw) {
292
+ const declared = parseInt(clRaw, 10);
293
+ if (!Number.isNaN(declared) && declared > entry.maxBodyBytes) {
294
+ setResponseStatus(event, 413);
295
+ return {
296
+ error: `Request body too large (max ${entry.maxBodyBytes} bytes)`,
297
+ };
298
+ }
299
+ }
300
+ }
287
301
  // Parse params based on method. On web-standard runtimes (Netlify
288
302
  // Functions, CF Workers), event.req IS the web Request — use .json()
289
303
  // directly. H3's readBody fails on those runtimes because it expects
@@ -1087,7 +1087,21 @@ function baseRedirectGuard(): Plugin {
1087
1087
  if (serveMountedEmbedRuntimeModule(server, req, res, base)) {
1088
1088
  return;
1089
1089
  }
1090
- req.url = stripMountedDevApiPath(req.url, base);
1090
+ // Nitro's pre-middleware only intercepts document/iframe/frame/empty
1091
+ // fetch-dest requests. For video/audio/image etc. it calls next() and
1092
+ // the post-internal Nitro middleware handles them instead. If we strip
1093
+ // the base path here for those requests, Vite's base middleware sees the
1094
+ // stripped path (e.g. /api/video/:id without /clips/) and responds with
1095
+ // a "did you mean /clips/api/video/:id" error before Nitro can handle it.
1096
+ // Only strip when the request type matches Nitro's pre-middleware gate.
1097
+ const secFetchDest = req.headers["sec-fetch-dest"] as
1098
+ | string
1099
+ | undefined;
1100
+ const isNitroPreHandled =
1101
+ !secFetchDest || /^(document|iframe|frame|empty)$/.test(secFetchDest);
1102
+ if (isNitroPreHandled) {
1103
+ req.url = stripMountedDevApiPath(req.url, base);
1104
+ }
1091
1105
  if (
1092
1106
  req.method === "HEAD" &&
1093
1107
  req.url &&
@@ -0,0 +1,162 @@
1
+ import { useActionMutation, useT } from "@agent-native/core/client";
2
+ import { IconMessagePlus, IconAt, IconMoodSmile } from "@tabler/icons-react";
3
+ import { useEffect, useRef, useState } from "react";
4
+
5
+ import { Button } from "@/components/ui/button";
6
+ import { Textarea } from "@/components/ui/textarea";
7
+ import { cn } from "@/lib/utils";
8
+
9
+ import { msToClock } from "./scrubber";
10
+
11
+ interface TimestampedCommentButtonProps {
12
+ enableComments: boolean;
13
+ onOpen: () => void;
14
+ className?: string;
15
+ }
16
+
17
+ /** Trigger that opens the docked comment composer, pinned to the current time. */
18
+ export function TimestampedCommentButton({
19
+ enableComments,
20
+ onOpen,
21
+ className,
22
+ }: TimestampedCommentButtonProps) {
23
+ const t = useT();
24
+ if (!enableComments) return null;
25
+ return (
26
+ <Button
27
+ type="button"
28
+ variant="outline"
29
+ size="sm"
30
+ className={cn("gap-1.5", className)}
31
+ onClick={onOpen}
32
+ >
33
+ <IconMessagePlus className="h-4 w-4" />
34
+ {t("commentsPanel.commentButton")}
35
+ </Button>
36
+ );
37
+ }
38
+
39
+ interface TimestampedCommentBarProps {
40
+ recordingId: string;
41
+ atMs: number;
42
+ onClose: () => void;
43
+ onAdded?: () => void;
44
+ className?: string;
45
+ }
46
+
47
+ /**
48
+ * Bottom-docked comment composer. Render inside a `relative` container (the
49
+ * video wrapper) so it overlays the bottom of the video at the captured moment.
50
+ */
51
+ export function TimestampedCommentBar({
52
+ recordingId,
53
+ atMs,
54
+ onClose,
55
+ onAdded,
56
+ className,
57
+ }: TimestampedCommentBarProps) {
58
+ const t = useT();
59
+ const [draft, setDraft] = useState("");
60
+ const textareaRef = useRef<HTMLTextAreaElement | null>(null);
61
+
62
+ const addComment = useActionMutation("add-comment");
63
+
64
+ useEffect(() => {
65
+ requestAnimationFrame(() => textareaRef.current?.focus());
66
+ }, []);
67
+
68
+ const insertAtCursor = (text: string) => {
69
+ const el = textareaRef.current;
70
+ if (!el) {
71
+ setDraft((d) => d + text);
72
+ return;
73
+ }
74
+ const start = el.selectionStart ?? draft.length;
75
+ const end = el.selectionEnd ?? draft.length;
76
+ const next = draft.slice(0, start) + text + draft.slice(end);
77
+ setDraft(next);
78
+ requestAnimationFrame(() => {
79
+ el.focus();
80
+ const pos = start + text.length;
81
+ el.setSelectionRange(pos, pos);
82
+ });
83
+ };
84
+
85
+ const submit = () => {
86
+ const content = draft.trim();
87
+ if (!content) return;
88
+ addComment.mutate(
89
+ { recordingId, content, videoTimestampMs: atMs },
90
+ {
91
+ onSuccess: () => {
92
+ setDraft("");
93
+ onAdded?.();
94
+ onClose();
95
+ },
96
+ },
97
+ );
98
+ };
99
+
100
+ return (
101
+ <div className={cn("absolute inset-x-0 bottom-0 z-30 p-3", className)}>
102
+ <div className="rounded-xl border border-border bg-background/95 p-3 shadow-lg backdrop-blur">
103
+ <Textarea
104
+ ref={textareaRef}
105
+ value={draft}
106
+ onChange={(e) => setDraft(e.target.value)}
107
+ onKeyDown={(e) => {
108
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
109
+ e.preventDefault();
110
+ submit();
111
+ }
112
+ if (e.key === "Escape") {
113
+ e.preventDefault();
114
+ onClose();
115
+ }
116
+ }}
117
+ placeholder={t("commentsPanel.composerPlaceholder")}
118
+ rows={2}
119
+ className="min-h-[3rem] resize-none border-0 bg-transparent px-1 text-sm shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
120
+ />
121
+ <div className="mt-2 flex items-center justify-between">
122
+ <div className="flex items-center gap-1">
123
+ <Button
124
+ type="button"
125
+ variant="ghost"
126
+ size="icon"
127
+ className="h-8 w-8 text-muted-foreground"
128
+ aria-label={t("commentsPanel.mentionSomeone")}
129
+ onClick={() => insertAtCursor("@")}
130
+ >
131
+ <IconAt className="h-4 w-4" />
132
+ </Button>
133
+ <Button
134
+ type="button"
135
+ variant="ghost"
136
+ size="icon"
137
+ className="h-8 w-8 text-muted-foreground"
138
+ aria-label={t("commentsPanel.addEmoji")}
139
+ onClick={() => insertAtCursor("🙂")}
140
+ >
141
+ <IconMoodSmile className="h-4 w-4" />
142
+ </Button>
143
+ </div>
144
+ <div className="flex items-center gap-2">
145
+ <Button type="button" variant="ghost" size="sm" onClick={onClose}>
146
+ {t("common.cancel")}
147
+ </Button>
148
+ <Button
149
+ type="button"
150
+ size="sm"
151
+ className="rounded-full"
152
+ disabled={!draft.trim() || addComment.isPending}
153
+ onClick={submit}
154
+ >
155
+ {t("commentsPanel.commentAt")} {msToClock(atMs)}
156
+ </Button>
157
+ </div>
158
+ </div>
159
+ </div>
160
+ </div>
161
+ );
162
+ }
@@ -850,6 +850,10 @@ const messages = {
850
850
  replyingTo: "الرد على",
851
851
  commentAt: "تعليق عند",
852
852
  writeReply: "اكتب ردًا...",
853
+ commentButton: "تعليق",
854
+ composerPlaceholder: "أضف تعليقًا…",
855
+ mentionSomeone: "أذكر شخصًا",
856
+ addEmoji: "أضف رمزًا تعبيريًا",
853
857
  },
854
858
  shareMeeting: {
855
859
  pageTitle: "ملاحظات الاجتماع · Clips",
@@ -878,6 +878,10 @@ Alle sichtbaren Änderungen für Clips-Nutzer werden hier dokumentiert. Du kanns
878
878
  replyingTo: "Antwort an",
879
879
  commentAt: "Kommentar bei",
880
880
  writeReply: "Antwort schreiben...",
881
+ commentButton: "Kommentar",
882
+ composerPlaceholder: "Kommentar hinzufügen…",
883
+ mentionSomeone: "Jemanden erwähnen",
884
+ addEmoji: "Emoji hinzufügen",
881
885
  },
882
886
  shareMeeting: {
883
887
  pageTitle: "Meeting-Notizen · Clips",
@@ -849,6 +849,10 @@ All notable user-facing changes to Clips are documented here. Open it any time f
849
849
  replyingTo: "Replying to",
850
850
  commentAt: "Comment at",
851
851
  writeReply: "Write a reply...",
852
+ commentButton: "Comment",
853
+ composerPlaceholder: "Add a comment…",
854
+ mentionSomeone: "Mention someone",
855
+ addEmoji: "Add emoji",
852
856
  },
853
857
  shareMeeting: {
854
858
  pageTitle: "Meeting notes · Clips",
@@ -870,6 +870,10 @@ Todos los cambios visibles para los usuarios de Clips se documentan aquí. Puede
870
870
  replyingTo: "Respondiendo a",
871
871
  commentAt: "Comentar en",
872
872
  writeReply: "Escribe una respuesta...",
873
+ commentButton: "Comentar",
874
+ composerPlaceholder: "Añadir un comentario…",
875
+ mentionSomeone: "Mencionar a alguien",
876
+ addEmoji: "Añadir emoji",
873
877
  },
874
878
  shareMeeting: {
875
879
  pageTitle: "Notas de reunión · Clips",
@@ -872,6 +872,10 @@ Tous les changements visibles par les utilisateurs de Clips sont documentés ici
872
872
  replyingTo: "Réponse à",
873
873
  commentAt: "Commentaire à",
874
874
  writeReply: "Écrire une réponse...",
875
+ commentButton: "Commenter",
876
+ composerPlaceholder: "Ajouter un commentaire…",
877
+ mentionSomeone: "Mentionner quelqu'un",
878
+ addEmoji: "Ajouter un emoji",
875
879
  },
876
880
  shareMeeting: {
877
881
  pageTitle: "Notes de réunion · Clips",
@@ -844,6 +844,10 @@ Clips में उपयोगकर्ताओं को दिखने व
844
844
  replyingTo: "जवाब दे रहे हैं",
845
845
  commentAt: "टिप्पणी करें",
846
846
  writeReply: "जवाब लिखें...",
847
+ commentButton: "टिप्पणी",
848
+ composerPlaceholder: "टिप्पणी जोड़ें…",
849
+ mentionSomeone: "किसी का उल्लेख करें",
850
+ addEmoji: "इमोजी जोड़ें",
847
851
  },
848
852
  shareMeeting: {
849
853
  pageTitle: "मीटिंग नोट्स · Clips",
@@ -860,6 +860,10 @@ Clips のユーザー向けの主な変更はここに記録されます。コ
860
860
  replyingTo: "返信先",
861
861
  commentAt: "コメント位置",
862
862
  writeReply: "返信を書く...",
863
+ commentButton: "コメント",
864
+ composerPlaceholder: "コメントを追加…",
865
+ mentionSomeone: "メンバーをメンション",
866
+ addEmoji: "絵文字を追加",
863
867
  },
864
868
  shareMeeting: {
865
869
  pageTitle: "会議メモ · Clips",
@@ -851,6 +851,10 @@ Clips의 모든 사용자 대상 변경 사항은 여기에 기록됩니다. 명
851
851
  replyingTo: "답글 대상",
852
852
  commentAt: "댓글 위치",
853
853
  writeReply: "답글 쓰기...",
854
+ commentButton: "댓글",
855
+ composerPlaceholder: "댓글 추가…",
856
+ mentionSomeone: "멘션하기",
857
+ addEmoji: "이모지 추가",
854
858
  },
855
859
  shareMeeting: {
856
860
  pageTitle: "회의 노트 · Clips",
@@ -867,6 +867,10 @@ Todas as mudanças visíveis para usuários do Clips são documentadas aqui. Voc
867
867
  replyingTo: "Respondendo a",
868
868
  commentAt: "Comentário em",
869
869
  writeReply: "Escreva uma resposta...",
870
+ commentButton: "Comentar",
871
+ composerPlaceholder: "Adicionar um comentário…",
872
+ mentionSomeone: "Mencionar alguém",
873
+ addEmoji: "Adicionar emoji",
870
874
  },
871
875
  shareMeeting: {
872
876
  pageTitle: "Notas da reunião · Clips",
@@ -817,6 +817,10 @@ Clips 中所有面向用户的重要更改都会记录在这里。你可以随
817
817
  replyingTo: "正在回复",
818
818
  commentAt: "评论于",
819
819
  writeReply: "写回复...",
820
+ commentButton: "评论",
821
+ composerPlaceholder: "添加评论…",
822
+ mentionSomeone: "提及某人",
823
+ addEmoji: "添加表情",
820
824
  },
821
825
  shareMeeting: {
822
826
  pageTitle: "会议笔记 · Clips",
@@ -810,6 +810,10 @@ const messages = {
810
810
  replyingTo: "正在回覆",
811
811
  commentAt: "評論於",
812
812
  writeReply: "寫回覆...",
813
+ commentButton: "留言",
814
+ composerPlaceholder: "新增留言…",
815
+ mentionSomeone: "提及某人",
816
+ addEmoji: "新增表情符號",
813
817
  },
814
818
  shareMeeting: {
815
819
  pageTitle: "會議筆記 · Clips",
@@ -44,6 +44,10 @@ import { InsightsPanel } from "@/components/player/insights-panel";
44
44
  import { ReactionsTray } from "@/components/player/reactions-tray";
45
45
  import { SettingsPanel } from "@/components/player/settings-panel";
46
46
  import { ShareRecordingPopover } from "@/components/player/share-dialog";
47
+ import {
48
+ TimestampedCommentButton,
49
+ TimestampedCommentBar,
50
+ } from "@/components/player/timestamped-comment-button";
47
51
  import { TranscriptPanel } from "@/components/player/transcript-panel";
48
52
  import {
49
53
  VideoPlayer,
@@ -210,7 +214,25 @@ export default function RecordingPage() {
210
214
  const [theaterMode, setTheaterMode] = useState(false);
211
215
  const [editing, setEditing] = useState(false);
212
216
  const [currentMs, setCurrentMs] = useState(0);
217
+ const [commentOpen, setCommentOpen] = useState(false);
218
+ const [commentAtMs, setCommentAtMs] = useState(0);
213
219
  const isCompactLayout = useIsCompactRecordingLayout();
220
+ // Resolve the playback position for reactions/comments. Native <video> exposes
221
+ // a live `currentTime`; Loom embeds render in a cross-origin iframe with no
222
+ // live time bridge, so we fall back to the last position the player reported
223
+ // via onTimeUpdate (seek/initial start).
224
+ const resolvePlaybackMs = useCallback(() => {
225
+ const liveCt = playerRef.current?.video?.currentTime;
226
+ if (
227
+ typeof liveCt === "number" &&
228
+ Number.isFinite(liveCt) &&
229
+ liveCt >= 0 &&
230
+ liveCt < 1e7
231
+ ) {
232
+ return Math.floor(liveCt * 1000);
233
+ }
234
+ return currentMs;
235
+ }, [currentMs]);
214
236
  const transcriptKickedRef = useRef<string | null>(null);
215
237
  // When the recording lands in the processing state but never flips to
216
238
  // 'ready', stop spinning forever and surface an error banner so the user
@@ -1082,7 +1104,7 @@ export default function RecordingPage() {
1082
1104
  <EditorLayout recordingId={recording.id} className="flex-1" />
1083
1105
  ) : (
1084
1106
  <>
1085
- <div className="flex-1 min-h-0">
1107
+ <div className="flex-1 min-h-0 relative">
1086
1108
  <VideoPlayer
1087
1109
  ref={playerRef}
1088
1110
  recordingId={recording.id}
@@ -1107,6 +1129,18 @@ export default function RecordingPage() {
1107
1129
  onTimeUpdate={(ms) => setCurrentMs(ms)}
1108
1130
  className="h-full"
1109
1131
  />
1132
+ {commentOpen ? (
1133
+ <TimestampedCommentBar
1134
+ recordingId={recording.id}
1135
+ atMs={commentAtMs}
1136
+ onClose={() => setCommentOpen(false)}
1137
+ onAdded={() => {
1138
+ setPanel("comments");
1139
+ if (isCompactLayout) setSidePanelOpen(true);
1140
+ void playerDataQ.refetch();
1141
+ }}
1142
+ />
1143
+ ) : null}
1110
1144
  </div>
1111
1145
 
1112
1146
  {/* Title + reactions row */}
@@ -1129,15 +1163,12 @@ export default function RecordingPage() {
1129
1163
  </span>
1130
1164
  </NavLink>
1131
1165
  ) : null}
1132
- <EditableRecordingTitle
1133
- recordingId={recording.id}
1134
- title={recording.title}
1135
- canEdit={canEdit}
1136
- displayTitle={visibleTitle}
1137
- showPendingSkeleton={showTitleSkeleton}
1138
- className="text-base font-semibold"
1139
- inputClassName="h-8 text-base font-semibold"
1140
- skeletonClassName="h-5 w-72 max-w-full"
1166
+ <TimestampedCommentButton
1167
+ enableComments={recording.enableComments}
1168
+ onOpen={() => {
1169
+ setCommentAtMs(resolvePlaybackMs());
1170
+ setCommentOpen(true);
1171
+ }}
1141
1172
  />
1142
1173
  {recording.description ? (
1143
1174
  <p className="text-sm text-muted-foreground line-clamp-2">
@@ -1150,14 +1181,7 @@ export default function RecordingPage() {
1150
1181
  disabled={!recording.enableReactions}
1151
1182
  onReact={(emoji) => {
1152
1183
  tracking.reportReaction(emoji);
1153
- const liveCt = playerRef.current?.video?.currentTime;
1154
- const liveMs =
1155
- typeof liveCt === "number" &&
1156
- Number.isFinite(liveCt) &&
1157
- liveCt >= 0 &&
1158
- liveCt < 1e7
1159
- ? Math.floor(liveCt * 1000)
1160
- : currentMs;
1184
+ const liveMs = resolvePlaybackMs();
1161
1185
  fetch(
1162
1186
  agentNativePath(
1163
1187
  "/_agent-native/actions/react-to-recording",
@@ -35,8 +35,10 @@ The local-files contract:
35
35
  `id` and `label`; and `Code` / `AnnotatedCode` / `Diff` are whitespace-sensitive
36
36
  — encode multiline code as JSON string attributes such as `code={"const x =\n y"}`
37
37
  (a static template literal is accepted only when it has no `${...}`
38
- interpolation). `plan local check` validates these required fields against the
39
- renderer schema.
38
+ interpolation). `plan local check` is a quick OFFLINE lint (a subset of the
39
+ renderer schema), so a green `check` does not guarantee the plan renders;
40
+ `plan local verify` is the authoritative validation against the real renderer
41
+ schema.
40
42
  - **Write a local MDX folder.** Use `plans/<slug>/` to check the artifact into the
41
43
  repo, or a repo-ignored/temporary folder such as `.agent-native/plans/<slug>/`
42
44
  or `/tmp/agent-native-plans/<slug>/` when it should not be checked in. The
@@ -61,9 +63,16 @@ The local-files contract:
61
63
  running local Plan app.
62
64
  - **Headless verify.** Run
63
65
  `npx @agent-native/core@latest plan local verify --dir <plan-dir> --kind <plan|recap>`.
64
- It starts the bridge, checks the private-network preflight and JSON payload,
65
- prints diagnostics, and exits. If the browser hangs on "Loading plan", fetch the
66
- `bridgeUrl` from the verify/serve JSON to read the concrete validation error.
66
+ It starts the bridge, checks the private-network preflight and JSON payload, AND
67
+ validates the content against the real renderer schema via the Plan app's
68
+ `validate-local-plan-source` action. A non-`ok` result with
69
+ `validation.valid: false` lists the renderer's exact schema-path issues (e.g.
70
+ `blocks[1].data.tabs[0]...`); fix those before handing off. If `validation.ran`
71
+ is `false`, the Plan app did not expose the validate endpoint (older/unreachable
72
+ deploy) — point `--app-url` at a current Plan app (e.g. a local
73
+ `http://localhost:8096`) for the authoritative check. If the browser hangs on
74
+ "Loading plan", fetch the `bridgeUrl` from the verify/serve JSON to read the
75
+ concrete validation error.
67
76
  - **Never call hosted tools for that plan/recap.** Do not call
68
77
  `create-visual-plan`, `create-ui-plan`, `create-prototype-plan`,
69
78
  `create-plan-design`, `create-visual-recap`, `create-visual-questions`,
@@ -35,8 +35,10 @@ The local-files contract:
35
35
  `id` and `label`; and `Code` / `AnnotatedCode` / `Diff` are whitespace-sensitive
36
36
  — encode multiline code as JSON string attributes such as `code={"const x =\n y"}`
37
37
  (a static template literal is accepted only when it has no `${...}`
38
- interpolation). `plan local check` validates these required fields against the
39
- renderer schema.
38
+ interpolation). `plan local check` is a quick OFFLINE lint (a subset of the
39
+ renderer schema), so a green `check` does not guarantee the plan renders;
40
+ `plan local verify` is the authoritative validation against the real renderer
41
+ schema.
40
42
  - **Write a local MDX folder.** Use `plans/<slug>/` to check the artifact into the
41
43
  repo, or a repo-ignored/temporary folder such as `.agent-native/plans/<slug>/`
42
44
  or `/tmp/agent-native-plans/<slug>/` when it should not be checked in. The
@@ -61,9 +63,16 @@ The local-files contract:
61
63
  running local Plan app.
62
64
  - **Headless verify.** Run
63
65
  `npx @agent-native/core@latest plan local verify --dir <plan-dir> --kind <plan|recap>`.
64
- It starts the bridge, checks the private-network preflight and JSON payload,
65
- prints diagnostics, and exits. If the browser hangs on "Loading plan", fetch the
66
- `bridgeUrl` from the verify/serve JSON to read the concrete validation error.
66
+ It starts the bridge, checks the private-network preflight and JSON payload, AND
67
+ validates the content against the real renderer schema via the Plan app's
68
+ `validate-local-plan-source` action. A non-`ok` result with
69
+ `validation.valid: false` lists the renderer's exact schema-path issues (e.g.
70
+ `blocks[1].data.tabs[0]...`); fix those before handing off. If `validation.ran`
71
+ is `false`, the Plan app did not expose the validate endpoint (older/unreachable
72
+ deploy) — point `--app-url` at a current Plan app (e.g. a local
73
+ `http://localhost:8096`) for the authoritative check. If the browser hangs on
74
+ "Loading plan", fetch the `bridgeUrl` from the verify/serve JSON to read the
75
+ concrete validation error.
67
76
  - **Never call hosted tools for that plan/recap.** Do not call
68
77
  `create-visual-plan`, `create-ui-plan`, `create-prototype-plan`,
69
78
  `create-plan-design`, `create-visual-recap`, `create-visual-questions`,