@agent-native/core-corpus 0.222.2-nightly-20261029152548 → 0.222.2

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 (85) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/clips/CHANGELOG.md +7 -10
  3. package/corpus/templates/clips/server/lib/content-clip-peer.ts +158 -28
  4. package/corpus/templates/clips/shared/content-clip-contract.ts +23 -1
  5. package/corpus/templates/content/.agents/skills/document-editing/SKILL.md +8 -0
  6. package/corpus/templates/content/CHANGELOG.md +4 -0
  7. package/corpus/templates/content/actions/_clip-embed-access.ts +31 -1
  8. package/corpus/templates/content/actions/add-clip-comment.ts +95 -0
  9. package/corpus/templates/content/actions/list-clip-comments.ts +68 -30
  10. package/corpus/templates/content/actions/resolve-clip-comment.ts +56 -0
  11. package/corpus/templates/content/app/clip-embed-messages.ts +22 -0
  12. package/corpus/templates/content/app/components/ContentErrorBoundary.tsx +50 -0
  13. package/corpus/templates/content/app/components/editor/CommentStack.tsx +3 -2
  14. package/corpus/templates/content/app/components/editor/CommentsSidebar.tsx +188 -62
  15. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +87 -10
  16. package/corpus/templates/content/app/components/editor/StaticDocumentBody.tsx +36 -0
  17. package/corpus/templates/content/app/components/editor/TableHoverControls.tsx +7 -2
  18. package/corpus/templates/content/app/components/editor/clip-embed/ClipEmbedBlock.tsx +113 -9
  19. package/corpus/templates/content/app/components/editor/clip-embed/ClipPlayer.tsx +33 -1
  20. package/corpus/templates/content/app/components/editor/clip-embed/clip-comments.ts +397 -0
  21. package/corpus/templates/content/app/components/editor/clip-embed/player-controls.tsx +17 -3
  22. package/corpus/templates/content/app/components/editor/clip-embed/scrubber-position.ts +12 -0
  23. package/corpus/templates/content/app/components/editor/clip-embed/scrubber.tsx +8 -13
  24. package/corpus/templates/content/app/components/editor/clip-embed/use-clip-comment-writes.ts +348 -0
  25. package/corpus/templates/content/app/components/editor/clip-embed/use-clip-playback.ts +22 -17
  26. package/corpus/templates/content/app/components/editor/comment-block-anchors.ts +13 -4
  27. package/corpus/templates/content/app/components/editor/comment-stacks.ts +6 -1
  28. package/corpus/templates/content/app/components/editor/discussion-panel.tsx +8 -5
  29. package/corpus/templates/content/app/components/editor/extensions/TablePresentation.ts +20 -2
  30. package/corpus/templates/content/app/components/editor/table-sizing.ts +749 -0
  31. package/corpus/templates/content/app/components/editor/table-view.ts +61 -84
  32. package/corpus/templates/content/app/global.css +119 -6
  33. package/corpus/templates/content/app/hooks/use-comments.ts +26 -7
  34. package/corpus/templates/content/app/lib/private-link-redirect.ts +68 -6
  35. package/corpus/templates/content/app/root.tsx +14 -50
  36. package/corpus/templates/content/app/routes/p.$id.tsx +35 -7
  37. package/corpus/templates/content/changelog/2026-10-22-shareable-local-file-copies-open-inline-with-document-editin.md +6 -0
  38. package/corpus/templates/content/changelog/2026-10-27-a-public-page-s-link-opens-a-read-only-copy-for-people-who-ar.md +6 -0
  39. package/corpus/templates/content/changelog/2026-10-27-edits-to-a-public-page-reach-its-readers-within-a-minute-and.md +6 -0
  40. package/corpus/templates/content/docs/product/capabilities/content.comment.page-owned.md +7 -0
  41. package/corpus/templates/content/parity/matrix.md +1 -1
  42. package/corpus/templates/content/parity/matrix.ts +11 -5
  43. package/corpus/templates/content/server/lib/chatgpt-directory-tools.ts +23 -20
  44. package/corpus/templates/content/server/lib/clips-peer.ts +67 -13
  45. package/corpus/templates/content/shared/clips-peer-contract.ts +40 -3
  46. package/corpus/templates/design/CHANGELOG.md +1 -0
  47. package/corpus/templates/design/README.md +14 -0
  48. package/corpus/templates/design/actions/enroll-first-output-experiment.ts +99 -0
  49. package/corpus/templates/design/actions/visual-edit-browser-request.ts +23 -1
  50. package/corpus/templates/design/app/hooks/use-agent-generating.ts +13 -0
  51. package/corpus/templates/design/app/lib/agent-run-status.ts +24 -0
  52. package/corpus/templates/design/app/lib/design-funnel-tracking.ts +5 -0
  53. package/corpus/templates/design/app/pages/design-editor/commands/start-retry-generation.ts +59 -0
  54. package/corpus/templates/design/app/pages/design-editor/domains/use-editor-generation-and-access.ts +6 -0
  55. package/corpus/templates/design/app/pages/design-editor/view/first-output-next-step.tsx +95 -15
  56. package/corpus/templates/design/changelog/2026-10-22-generation-sessions-and-coding-handoffs-open-the-design-inli.md +6 -0
  57. package/corpus/templates/design/server/db/schema.ts +11 -0
  58. package/corpus/templates/design/server/lib/builder-identity-enabled.ts +23 -0
  59. package/corpus/templates/design/server/lib/builder-identity.ts +2 -21
  60. package/corpus/templates/design/server/lib/chatgpt-directory-tools.ts +16 -0
  61. package/corpus/templates/design/server/plugins/builder-host-embed-headers.ts +2 -7
  62. package/corpus/templates/design/server/plugins/db.ts +11 -0
  63. package/corpus/templates/design/shared/builder-host-origins.ts +28 -0
  64. package/corpus/templates/factory/.agents/skills/review-latest-feedback/SKILL.md +37 -8
  65. package/corpus/templates/slides/CHANGELOG.md +6 -0
  66. package/corpus/templates/slides/changelog/2026-10-29-deck-change-notifications-are-now-only-sent-to-people-who-ca.md +6 -0
  67. package/corpus/templates/slides/server/handlers/decks.ts +70 -24
  68. package/package.json +1 -1
  69. package/corpus/templates/analytics/.deploy-tmp/agent-native-provided-plugins.mjs +0 -10
  70. package/corpus/templates/assets/.deploy-tmp/agent-native-provided-plugins.mjs +0 -10
  71. package/corpus/templates/brain/.deploy-tmp/agent-native-provided-plugins.mjs +0 -10
  72. package/corpus/templates/calendar/.deploy-tmp/agent-native-provided-plugins.mjs +0 -10
  73. package/corpus/templates/chat/.deploy-tmp/agent-native-provided-plugins.mjs +0 -10
  74. package/corpus/templates/clips/.deploy-tmp/agent-native-provided-plugins.mjs +0 -10
  75. package/corpus/templates/content/.deploy-tmp/agent-native-provided-plugins.mjs +0 -10
  76. package/corpus/templates/crm/.deploy-tmp/agent-native-provided-plugins.mjs +0 -10
  77. package/corpus/templates/design/.deploy-tmp/agent-native-provided-plugins.mjs +0 -10
  78. package/corpus/templates/design/.generated/bridge/native-builtin-metadata.generated.ts +0 -326
  79. package/corpus/templates/design/.generated/bridge/native-worker-source.generated.ts +0 -6
  80. package/corpus/templates/dispatch/.deploy-tmp/agent-native-provided-plugins.mjs +0 -10
  81. package/corpus/templates/forms/.deploy-tmp/agent-native-provided-plugins.mjs +0 -10
  82. package/corpus/templates/mail/.deploy-tmp/agent-native-provided-plugins.mjs +0 -10
  83. package/corpus/templates/plan/.deploy-tmp/agent-native-provided-plugins.mjs +0 -10
  84. package/corpus/templates/slides/.deploy-tmp/agent-native-provided-plugins.mjs +0 -10
  85. package/corpus/templates/slides-2/.deploy-tmp/agent-native-provided-plugins.mjs +0 -10
package/corpus/README.md CHANGED
@@ -32,4 +32,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core-corpus/corpu
32
32
 
33
33
  ## Generated Counts
34
34
 
35
- - template files: 15129
35
+ - template files: 15090
@@ -3,6 +3,12 @@
3
3
  All notable user-facing changes to Clips are documented here. Open it any time
4
4
  from the command menu (Cmd+K → "What's new") or from Settings.
5
5
 
6
+ ## 2026-10-29
7
+
8
+ ### Changed
9
+
10
+ - Screenshots are available when enabled in Labs.
11
+
6
12
  ## 2026-10-28
7
13
 
8
14
  ### Improved
@@ -13,6 +19,7 @@ from the command menu (Cmd+K → "What's new") or from Settings.
13
19
 
14
20
  ### Fixed
15
21
 
22
+ - Fixed the Share → Advanced popover so the Save buttons fit and the password toggle matches other switches
16
23
  - Clips desktop preserves your server choice across updates and reconnects after temporary sign-in outages.
17
24
  - Recording chapters remain reachable when the list extends below the player.
18
25
  - The Chrome extension recorder has more room and a clearer feedback link.
@@ -1600,14 +1607,4 @@ from the command menu (Cmd+K → "What's new") or from Settings.
1600
1607
 
1601
1608
  - Mobile recording pages keep videos in a 16:9 player with activity below.
1602
1609
 
1603
- ## 2026-07-02
1604
-
1605
- ### Added
1606
-
1607
- - Private recordings can be shared with agents through temporary links without making them public.
1608
-
1609
- ### Fixed
1610
-
1611
- - Recordings now wait for uploaded media to play from storage before finishing, and playback falls back when a compressed storage copy is temporarily broken.
1612
-
1613
1610
  For the full list of updates, see the [changelog folder](./changelog/).
@@ -13,7 +13,18 @@ import {
13
13
  } from "@agent-native/core/server";
14
14
  import { ForbiddenError, resolveAccess } from "@agent-native/core/sharing";
15
15
  import { isEmailDerivedName } from "@agent-native/core/user-profile";
16
- import { and, asc, eq, isNull, sql } from "drizzle-orm";
16
+ import {
17
+ and,
18
+ asc,
19
+ count,
20
+ eq,
21
+ inArray,
22
+ isNotNull,
23
+ isNull,
24
+ sql,
25
+ type SQL,
26
+ } from "drizzle-orm";
27
+ import { alias } from "drizzle-orm/pg-core";
17
28
  import { getQuery, getRequestHeader, getRouterParam, type H3Event } from "h3";
18
29
  import { z } from "zod";
19
30
 
@@ -29,8 +40,13 @@ import {
29
40
  CONTENT_CLIP_CLAIMS,
30
41
  CONTENT_CLIP_COMMENT_AUTHOR_CHARS,
31
42
  CONTENT_CLIP_COMMENT_PREVIEW_CHARS,
43
+ CONTENT_CLIP_COMMENT_TEXT_CHARS,
44
+ CONTENT_CLIP_MAX_COMMENT_CHARS,
32
45
  CONTENT_CLIP_MAX_COMMENT_THREADS,
46
+ CONTENT_CLIP_MAX_LISTED_REPLIES,
33
47
  CONTENT_CLIP_MAX_TOKEN_SECONDS,
48
+ CONTENT_CLIP_MAX_VIDEO_TIMESTAMP_MS,
49
+ CONTENT_CLIP_OPEN_THREADS_VIEW,
34
50
  CONTENT_CLIP_PUBLIC_READER,
35
51
  CONTENT_CLIP_RECORDING_TITLE_CHARS,
36
52
  CONTENT_CLIP_SCOPES,
@@ -92,10 +108,8 @@ const MAX_DOCUMENT_ID_CHARS = 256;
92
108
  const MAX_ISSUER_CHARS = 2048;
93
109
  const MAX_VIEWER_NAME_CHARS = 200;
94
110
  const ISSUED_AT_SKEW_SECONDS = 60;
95
- const MAX_COMMENT_CHARS = 10_000;
96
111
  const MAX_COMMENT_ID_CHARS = 128;
97
112
  const MAX_EMOJI_CHARS = 32;
98
- const MAX_VIDEO_TIMESTAMP_MS = 7 * 24 * 60 * 60 * 1000;
99
113
  const MAX_BODY_BYTES = 64 * 1024;
100
114
  const viewerEmailSchema = z.string().email().max(320);
101
115
 
@@ -556,50 +570,152 @@ const commentListQuerySchema = z.object({
556
570
  .transform(Number)
557
571
  .pipe(z.number().int().min(1).max(CONTENT_CLIP_MAX_COMMENT_THREADS))
558
572
  .default(CONTENT_CLIP_MAX_COMMENT_THREADS),
573
+ view: z.literal(CONTENT_CLIP_OPEN_THREADS_VIEW).optional(),
559
574
  });
560
575
 
561
576
  /**
562
577
  * The first `limit` thread starts by video time, as `list-comments` orders
563
- * them, reading one more to tell a full page from the whole list. Text and
564
- * names are cut to the contract's lengths. A signed-out reader's list carries
565
- * no commenter's email, nor a name made from one.
578
+ * them, reading one more to tell a full page from the whole list. The
579
+ * open-threads view lists only open threads, each with its replies up to the
580
+ * contract's bound. Text and names are cut to the contract's lengths. A
581
+ * signed-out reader's list carries no commenter's email, nor a name made
582
+ * from one.
566
583
  */
567
- async function listCommentThreads(peer: ContentClipPeer, limit: number) {
584
+ async function listCommentThreads(
585
+ peer: ContentClipPeer,
586
+ limit: number,
587
+ openThreads: boolean,
588
+ ) {
568
589
  const { recordingId } = peer;
569
590
  const signedOut = peer.viewerEmail === null;
570
591
  await assertCommentAccess(recordingId, "read");
571
592
  const comments = schema.recordingComments;
593
+ const text = sql<string>`left(${comments.content}, ${
594
+ openThreads
595
+ ? CONTENT_CLIP_COMMENT_TEXT_CHARS
596
+ : CONTENT_CLIP_COMMENT_PREVIEW_CHARS
597
+ })`;
572
598
  const rows = await getDb()
573
599
  .select({
574
600
  id: comments.id,
575
601
  authorEmail: comments.authorEmail,
576
602
  authorName: comments.authorName,
577
- content: sql<string>`left(${comments.content}, ${CONTENT_CLIP_COMMENT_PREVIEW_CHARS})`,
603
+ content: text,
578
604
  videoTimestampMs: comments.videoTimestampMs,
605
+ createdAt: comments.createdAt,
579
606
  })
580
607
  .from(comments)
581
608
  .where(
582
- and(eq(comments.recordingId, recordingId), isNull(comments.parentId)),
609
+ and(
610
+ eq(comments.recordingId, recordingId),
611
+ isNull(comments.parentId),
612
+ openThreads ? eq(comments.resolved, false) : undefined,
613
+ ),
614
+ )
615
+ .orderBy(
616
+ asc(comments.videoTimestampMs),
617
+ asc(comments.createdAt),
618
+ asc(comments.id),
583
619
  )
584
- .orderBy(asc(comments.videoTimestampMs), asc(comments.createdAt))
585
620
  .limit(limit + 1);
621
+ const author = <
622
+ Row extends { authorEmail: string; authorName: string | null },
623
+ >(
624
+ row: Row,
625
+ ) => {
626
+ const authorName =
627
+ signedOut && isEmailDerivedName(row.authorName, row.authorEmail)
628
+ ? null
629
+ : row.authorName;
630
+ return {
631
+ ...row,
632
+ authorEmail: signedOut
633
+ ? null
634
+ : row.authorEmail.slice(0, CONTENT_CLIP_COMMENT_AUTHOR_CHARS),
635
+ authorName:
636
+ authorName?.slice(0, CONTENT_CLIP_COMMENT_AUTHOR_CHARS) ?? null,
637
+ };
638
+ };
586
639
  const threads = await hydrateCommentAuthorNames(rows.slice(0, limit));
640
+ const truncated = rows.length > limit;
641
+ if (!openThreads) {
642
+ return {
643
+ comments: threads.map(({ createdAt: _createdAt, ...thread }) =>
644
+ author(thread),
645
+ ),
646
+ truncated,
647
+ };
648
+ }
649
+
650
+ const threadIds = threads.map((thread) => thread.id);
651
+ const replies = threadIds.length
652
+ ? await listThreadReplies(recordingId, threadIds, text)
653
+ : { rows: [], counts: new Map<string, number>() };
654
+ const repliesOf = new Map<string, unknown[]>();
655
+ for (const { threadId, ...reply } of await hydrateCommentAuthorNames(
656
+ replies.rows,
657
+ )) {
658
+ repliesOf.set(threadId, [
659
+ ...(repliesOf.get(threadId) ?? []),
660
+ author(reply),
661
+ ]);
662
+ }
587
663
  return {
588
- comments: threads.map((thread) => {
589
- const authorName =
590
- signedOut && isEmailDerivedName(thread.authorName, thread.authorEmail)
591
- ? null
592
- : thread.authorName;
593
- return {
594
- ...thread,
595
- authorEmail: signedOut
596
- ? null
597
- : thread.authorEmail.slice(0, CONTENT_CLIP_COMMENT_AUTHOR_CHARS),
598
- authorName:
599
- authorName?.slice(0, CONTENT_CLIP_COMMENT_AUTHOR_CHARS) ?? null,
600
- };
601
- }),
602
- truncated: rows.length > limit,
664
+ comments: threads.map((thread) => ({
665
+ ...author(thread),
666
+ replyCount: replies.counts.get(thread.id) ?? 0,
667
+ replies: repliesOf.get(thread.id) ?? [],
668
+ })),
669
+ truncated,
670
+ };
671
+ }
672
+
673
+ /**
674
+ * The listed threads' replies, oldest first, from the earliest thread on and
675
+ * no more than the contract's bound, with how many each thread has.
676
+ */
677
+ async function listThreadReplies(
678
+ recordingId: string,
679
+ threadIds: string[],
680
+ text: SQL<string>,
681
+ ) {
682
+ const comments = schema.recordingComments;
683
+ const root = alias(comments, "thread_start");
684
+ const inThreads = and(
685
+ eq(comments.recordingId, recordingId),
686
+ inArray(comments.threadId, threadIds),
687
+ isNotNull(comments.parentId),
688
+ );
689
+ const [rows, counts] = await Promise.all([
690
+ getDb()
691
+ .select({
692
+ id: comments.id,
693
+ threadId: comments.threadId,
694
+ authorEmail: comments.authorEmail,
695
+ authorName: comments.authorName,
696
+ content: text,
697
+ createdAt: comments.createdAt,
698
+ })
699
+ .from(comments)
700
+ .innerJoin(root, eq(root.id, comments.threadId))
701
+ .where(inThreads)
702
+ .orderBy(
703
+ asc(root.videoTimestampMs),
704
+ asc(root.createdAt),
705
+ asc(root.id),
706
+ asc(comments.createdAt),
707
+ asc(comments.id),
708
+ )
709
+ .limit(CONTENT_CLIP_MAX_LISTED_REPLIES),
710
+ getDb()
711
+ .select({ threadId: comments.threadId, replies: count() })
712
+ .from(comments)
713
+ .where(inThreads)
714
+ .groupBy(comments.threadId),
715
+ ]);
716
+ return {
717
+ rows,
718
+ counts: new Map(counts.map((row) => [row.threadId, Number(row.replies)])),
603
719
  };
604
720
  }
605
721
 
@@ -629,21 +745,35 @@ export async function handleContentClipComments(
629
745
  );
630
746
  }
631
747
  try {
632
- return json(await listCommentThreads(peer, query.data.limit));
748
+ return json(
749
+ await listCommentThreads(
750
+ peer,
751
+ query.data.limit,
752
+ query.data.view === CONTENT_CLIP_OPEN_THREADS_VIEW,
753
+ ),
754
+ );
633
755
  } catch (error) {
634
756
  return forbiddenResponse(error);
635
757
  }
636
758
  });
637
759
  }
638
760
 
639
- const commentText = z.string().trim().min(1).max(MAX_COMMENT_CHARS);
761
+ const commentText = z
762
+ .string()
763
+ .trim()
764
+ .min(1)
765
+ .max(CONTENT_CLIP_MAX_COMMENT_CHARS);
640
766
  const commentId = z.string().trim().min(1).max(MAX_COMMENT_ID_CHARS);
641
767
  const commentWriteSchema = z.discriminatedUnion("operation", [
642
768
  z
643
769
  .object({
644
770
  operation: z.literal("add"),
645
771
  content: commentText,
646
- videoTimestampMs: z.number().int().min(0).max(MAX_VIDEO_TIMESTAMP_MS),
772
+ videoTimestampMs: z
773
+ .number()
774
+ .int()
775
+ .min(0)
776
+ .max(CONTENT_CLIP_MAX_VIDEO_TIMESTAMP_MS),
647
777
  })
648
778
  .strict(),
649
779
  z
@@ -77,10 +77,32 @@ export const CONTENT_CLIP_PLAYBACK_GRANT_SECONDS = 5 * 60;
77
77
  /**
78
78
  * The most comment threads one comment-list call returns. Content asks with
79
79
  * `?limit=<n>`, n from 1 to this, and gets the first n thread starts by video
80
- * time, with `truncated: true` when there are more. Replies never come back.
80
+ * time, with `truncated: true` when there are more. Replies come back only in
81
+ * the open-threads view.
81
82
  */
82
83
  export const CONTENT_CLIP_MAX_COMMENT_THREADS = 500;
83
84
 
85
+ /**
86
+ * The comment list for a document's comment margin, asked for with
87
+ * `?view=open-threads`: open threads only, each with `createdAt`, its text
88
+ * cut to `CONTENT_CLIP_COMMENT_TEXT_CHARS` rather than the preview length,
89
+ * `replyCount`, and `replies` oldest first, each `{ id, authorEmail,
90
+ * authorName, content, createdAt }` cut the same way. At most
91
+ * `CONTENT_CLIP_MAX_LISTED_REPLIES` replies come back across the list, from
92
+ * the earliest thread on, so a thread whose `replies` is shorter than its
93
+ * `replyCount` had some held back. A Clips from before this view ignores it
94
+ * and answers with no `replies`.
95
+ */
96
+ export const CONTENT_CLIP_OPEN_THREADS_VIEW = "open-threads";
97
+ export const CONTENT_CLIP_COMMENT_TEXT_CHARS = 2_000;
98
+ export const CONTENT_CLIP_MAX_LISTED_REPLIES = 500;
99
+
100
+ /** The longest comment or reply Content may add or edit a Clip comment to. */
101
+ export const CONTENT_CLIP_MAX_COMMENT_CHARS = 10_000;
102
+
103
+ /** The latest moment of a video a comment from Content may sit at. */
104
+ export const CONTENT_CLIP_MAX_VIDEO_TIMESTAMP_MS = 7 * 24 * 60 * 60 * 1000;
105
+
84
106
  /**
85
107
  * A listed thread start's text is cut to this many characters, and its
86
108
  * author's name and email to `CONTENT_CLIP_COMMENT_AUTHOR_CHARS`, so Content
@@ -301,6 +301,8 @@ On a collection page, each Blocks field tab has its own comments. Pass `fieldId`
301
301
 
302
302
  A thread can sit on a whole video instead of on text. Pass `blockId` with the video's `id` attribute from `get-document`'s content (`<video src="…" id="video-…">`), plus `fieldId` for a video in another tab. The thread quotes no text; people see it beside the video, and `list-comments` returns `block_id` and `block_type` on it. A video saved without an `id` has none to pass: give it a new unique `video-` id with `edit-document`, then comment with that id. `add-comment` refuses the id such a video goes by until then (`comment_block_id_unsaved`), because that id passes to another video when videos with its source are added or reordered. Each video's `id` is its own: when you copy a `<video>` line, give the copy a new unique `video-` id or none. An edit that gives a second video an id the page already uses is refused (`DOCUMENT_VIDEO_ID_SHARED`); a new page isn't checked, since no thread is on it yet. Videos that already share an id show none of its threads, and `add-comment` refuses that id (`comment_block_id_shared`); give the video you mean a new id with `edit-document` first. Never give it a `block_id` from `list-comments`; one no video carries may be a deleted video's, and reusing it moves that thread onto this video. A thread made before videos had ids quotes the word "Video"; on a page with exactly one video it shows on that video, and with more it stays on its quote.
303
303
 
304
+ A Clip block (`<ClipEmbed recordingId="…" />`, behind the `content.clip-embed` flag) keeps its comments in Clips, so `list-comments` never returns them and every page holding that Clip shows the same ones. People see each open thread as its own card beside the Clip, at its moment of the video. `list-clip-comments` returns a Clip's open threads in the order of their moments, with replies; `add-clip-comment` starts a thread at `videoTimestampMs` (milliseconds from the start of the video) or replies with a `threadId` from that list; `resolve-clip-comment` resolves a thread, which Clips lets only the thread's author and the Clip's editors do (`clip_forbidden`). Writing needs comment access to the page. Clip comments take no `mentions` and no Ask AI, and Content sends no email for them. When the Clip's comments are off, every write fails with `clip_comments_disabled`, and so does reading for anyone but the Clip's owner.
305
+
304
306
  ```bash
305
307
  # List threads (returns anchor fields + parsed mentions)
306
308
  pnpm action list-comments --documentId abc123
@@ -325,6 +327,12 @@ pnpm action add-comment --documentId abc123 --threadId t123 --parentId c123 --co
325
327
  # Resolve / reopen the whole thread
326
328
  pnpm action update-comment --id c123 --resolved true
327
329
  pnpm action update-comment --id c123 --resolved false
330
+
331
+ # A Clip's comments: list, start one at 0:42, reply, resolve
332
+ pnpm action list-clip-comments --documentId abc123 --recordingId rec456
333
+ pnpm action add-clip-comment --documentId abc123 --recordingId rec456 --videoTimestampMs 42000 --content "Cut this pause"
334
+ pnpm action add-clip-comment --documentId abc123 --recordingId rec456 --threadId cc789 --content "Done"
335
+ pnpm action resolve-clip-comment --documentId abc123 --recordingId rec456 --threadId cc789 --resolved true
328
336
  ```
329
337
 
330
338
  `--authorName` sets the comment's display name; it defaults to a name derived from the author's email.
@@ -18,10 +18,13 @@ time from the command menu (Cmd+K → "What's new").
18
18
 
19
19
  ### Improved
20
20
 
21
+ - A public page's link opens a read-only copy for people who aren't signed in, instead of asking them to sign in
21
22
  - An AI suggestion in a comment thread uses the same group as suggested changes in the margin: its summary, edit count, Accept all and Reject all come first, and opening it shows each edit with the usual check and X.
22
23
 
23
24
  ### Fixed
24
25
 
26
+ - Edits to a public page reach its readers within a minute, and making it private or moving it to Trash takes it down just as fast
27
+ - Two collections connected to the same Builder model can no longer push over each other's changes to the same entry at the same moment.
25
28
  - Restoring page history preserves text without exposing unauthorized private callout images
26
29
  - An agent's Accept on words someone is still typing in returns the suggestion to pending instead of failing with a server error
27
30
  - Opening a page you can't access goes straight to the no-access screen without a brief error first, including when you come back to it
@@ -97,6 +100,7 @@ time from the command menu (Cmd+K → "What's new").
97
100
 
98
101
  ### Improved
99
102
 
103
+ - Shareable local-file copies open inline with document editing and sharing controls in compatible agent hosts.
100
104
  - Approval links from a connected agent, such as Claude Code, now open the review inside the app, in its own theme.
101
105
  - Tables get Header row, Header column and Fit to width in their block menu, colours for rows and columns, and menus that close the moment you press Escape.
102
106
  - The block menu turns a block into another kind, colours it and comments on it, filters as you type, and shows each action’s shortcut. It says why a locked block can’t be duplicated or deleted, and a click beside the page lets go of a selected block.
@@ -70,6 +70,10 @@ const FAILURES: Record<
70
70
  statusCode: 403,
71
71
  message: "Comments are turned off for this Clip",
72
72
  },
73
+ [CLIP_PLAYBACK_ERRORS.commentRequired]: {
74
+ statusCode: 403,
75
+ message: "Only people who can comment on the page can comment on its Clips",
76
+ },
73
77
  [CLIP_PLAYBACK_ERRORS.forbidden]: {
74
78
  statusCode: 403,
75
79
  message: "Clips refused this reader",
@@ -121,14 +125,39 @@ async function documentState(documentId: string) {
121
125
  export async function authorizeClipReader(
122
126
  documentId: string,
123
127
  ): Promise<ClipReader> {
128
+ return (await authorizeReader(documentId)).reader;
129
+ }
130
+
131
+ /**
132
+ * The reader Content may vouch for to Clips to comment on a Clip in this
133
+ * page: a signed-in reader who can comment on the page itself. A signed-out
134
+ * reader of a public page never can.
135
+ */
136
+ export async function authorizeClipCommenter(
137
+ documentId: string,
138
+ ): Promise<ClipUploader> {
139
+ const { reader, role } = await authorizeReader(documentId);
140
+ if (
141
+ reader.kind !== "signed-in" ||
142
+ !role ||
143
+ !roleSatisfies(role, "commenter")
144
+ ) {
145
+ failClip(CLIP_PLAYBACK_ERRORS.commentRequired);
146
+ }
147
+ return reader;
148
+ }
149
+
150
+ async function authorizeReader(documentId: string) {
124
151
  const email = getRequestUserEmail();
125
152
  const readerEmail = email && !isPublicViewerOwner(email) ? email : null;
126
153
 
154
+ let role: Parameters<typeof roleSatisfies>[0] | null = null;
127
155
  if (readerEmail) {
128
156
  const access = await resolveDocumentAccess(documentId, {
129
157
  skipResourceBody: true,
130
158
  });
131
159
  if (!access) failClip(CLIP_PLAYBACK_ERRORS.documentNotFound);
160
+ role = access.role;
132
161
  }
133
162
  const state = await documentState(documentId);
134
163
  if (!state || state.trashedAt) {
@@ -147,13 +176,14 @@ export async function authorizeClipReader(
147
176
  });
148
177
  if (!enabled) failClip(CLIP_PLAYBACK_ERRORS.disabled);
149
178
 
150
- return readerEmail
179
+ const reader: ClipReader = readerEmail
151
180
  ? {
152
181
  kind: "signed-in",
153
182
  email: readerEmail,
154
183
  name: getRequestUserName() ?? null,
155
184
  }
156
185
  : { kind: "public" };
186
+ return { reader, role };
157
187
  }
158
188
 
159
189
  /**
@@ -0,0 +1,95 @@
1
+ import { defineAction, fail } from "@agent-native/core/action";
2
+ import { z } from "zod";
3
+
4
+ import {
5
+ type ClipCommentWrite,
6
+ writeClipComment,
7
+ } from "../server/lib/clips-peer.js";
8
+ import {
9
+ CLIP_PLAYBACK_ERRORS,
10
+ CONTENT_CLIP_MAX_COMMENT_CHARS,
11
+ CONTENT_CLIP_MAX_VIDEO_TIMESTAMP_MS,
12
+ } from "../shared/clips-peer-contract.js";
13
+ import {
14
+ authorizeClipCommenter,
15
+ clipEmbedArgsSchema,
16
+ failClip,
17
+ } from "./_clip-embed-access.js";
18
+
19
+ const answerSchema = z.object({ id: z.string(), threadId: z.string() });
20
+
21
+ // Coercion would read null or a blank as 0:00 and start a real thread there.
22
+ const timestampArg = z.preprocess(
23
+ (value) =>
24
+ typeof value === "string" && value.trim() !== "" ? Number(value) : value,
25
+ z.number().int().min(0).max(CONTENT_CLIP_MAX_VIDEO_TIMESTAMP_MS),
26
+ );
27
+
28
+ const ONE_TARGET =
29
+ "Pass videoTimestampMs to start a thread or threadId to reply, not both";
30
+
31
+ export default defineAction({
32
+ description:
33
+ "Comment on a Clip placed in a page: start a thread at a moment of its video (videoTimestampMs), or reply to one of its threads (threadId from list-clip-comments). The comment is saved in Clips as the caller, who must be able to comment on the page. Fails with clip_comments_disabled when the Clip's comments are off.",
34
+ mcpAnnotations: {
35
+ readOnlyHint: false,
36
+ destructiveHint: false,
37
+ idempotentHint: false,
38
+ openWorldHint: false,
39
+ },
40
+ schema: clipEmbedArgsSchema
41
+ .extend({
42
+ content: z
43
+ .string()
44
+ .trim()
45
+ .min(1)
46
+ .max(CONTENT_CLIP_MAX_COMMENT_CHARS)
47
+ .describe("Comment text"),
48
+ videoTimestampMs: timestampArg
49
+ .optional()
50
+ .describe(
51
+ "Moment of the video to start a thread at, in milliseconds from its start. Omit when replying.",
52
+ ),
53
+ threadId: z
54
+ .string()
55
+ .trim()
56
+ .min(1)
57
+ .max(128)
58
+ .optional()
59
+ .describe(
60
+ "Id of the thread to reply to, from list-clip-comments. Omit to start a thread.",
61
+ ),
62
+ })
63
+ .refine(
64
+ (args) =>
65
+ (args.videoTimestampMs === undefined) !== (args.threadId === undefined),
66
+ ONE_TARGET,
67
+ ),
68
+ outputSchema: answerSchema,
69
+ run: async ({
70
+ documentId,
71
+ recordingId,
72
+ content,
73
+ videoTimestampMs,
74
+ threadId,
75
+ }) => {
76
+ const write: ClipCommentWrite | null =
77
+ threadId !== undefined
78
+ ? { operation: "reply", commentId: threadId, content }
79
+ : videoTimestampMs !== undefined
80
+ ? { operation: "add", content, videoTimestampMs }
81
+ : null;
82
+ if (!write) fail(ONE_TARGET, { errorCode: "clip_comment_target" });
83
+ const reader = await authorizeClipCommenter(documentId);
84
+ const result = await writeClipComment({
85
+ reader,
86
+ documentId,
87
+ recordingId,
88
+ write,
89
+ });
90
+ if (!result.ok) failClip(result.code);
91
+ const parsed = answerSchema.safeParse(result.body);
92
+ if (!parsed.success) failClip(CLIP_PLAYBACK_ERRORS.unreachable);
93
+ return { id: parsed.data.id, threadId: parsed.data.threadId };
94
+ },
95
+ });