Skip to content

Show a Clip's video comments in the page margin and comment at a moment - #8377

Open
3mdistal wants to merge 7 commits into
mainfrom
clips-content-margin-comments
Open

3mdistal wants to merge 7 commits into
mainfrom
clips-content-margin-comments

Conversation

@3mdistal

@3mdistal 3mdistal commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

People reviewing a video in a Content page need to read and leave comments at the moments that matter, beside the page's own comments. Before this PR, a Clip placed in a page (behind the content.clip-embed flag, off in production) showed its Clips comments only as scrubber markers you hover. You couldn't read a whole thread or its replies, reply, resolve, or comment at the playhead from the page. Video comments never appeared in the margin where the page's text comments are.

This is step A5 of "Clips videos embedded natively in Content docs". It builds on #8166 (comments on a whole video), #8090 (Clips' side), #8147 (playing a Clip in a page) and #8182/#8183 (uploading a Clip from a page), all merged. All of it is behind content.clip-embed. With the flag off, nothing here runs.

Approach

Each open Clip thread is its own comment card in the page's margin. The margin already takes a list of threads and places each beside the text or block it points at. A Clip's open threads join that list as ordinary threads with a clip field (recording, Clips thread id, moment, and whether the reader may reply or resolve). Their margin ids are clip:<recordingId>:<threadId>, so they can't collide with the page's own ids. The card's quote line reads the moment ("At 0:06") in the reader's language. The comments stay in Clips: Content stores nothing new, so every page holding the Clip, a duplicated page included, shows the same ones.

The Clip block and the margin meet through a small per-page registry (clip-embed/clip-comments.ts). Each Clip block publishes its threads, a seek function and its playhead. The page editor publishes what the margin can do (start a comment at a moment, open a thread) and what it has open. A block can't reach the margin's React state directly, and the margin can't reach a video element, so each side registers what it offers and looks up what it needs.

Stacks work as they do for text. The Clip's player carries its threads' ids in data-clip-comment-threads, so the stack layout measures it as it measures a block with several threads: two or more open threads fold into one card. That attribute has its own name so that clicking or resting on the player, which you need to do to play it, never opens a thread. A folded Clip stack fronts the thread the playhead last passed. A new comment opens on top of a folded stack without opening it. There is no unresolved count, and focusing the block doesn't open anything.

Comment at the playhead. The player's new Comment button pauses the video and opens the margin's composer reading "At m:ss" before anything is typed. Posting calls Content's add-clip-comment. The card and its scrubber marker show at once with a temporary id. If Clips refuses, both go away and the draft comes back with the same error toast text comments use. A timeout gives the draft back too: unlike a text comment, a Clip comment has no card that later finds out whether it was saved. Clicking a card's moment moves the video there, and clicking a scrubber marker opens its card. Replies use add-clip-comment with the thread's id, and Resolve uses resolve-clip-comment. Both show at once and are taken back on failure.

Writes reuse the existing trust path between Content and Clips. The new actions check, in Content, that the caller is signed in and can comment on the page, and that the flag is on for them. Only then does Content mint the same one-minute token as before, with the existing content-clip:comment scope, and post to Clips' existing comment route from #8090 (POST /api/content-clips/:recordingId/comments, operations add, reply, resolve). Clips still makes the final call: comments turned off (clip_comments_disabled), or a thread that isn't the reader's to resolve (clip_forbidden). A signed-out reader of a public page never gets a comment token. They see the Clip's markers and no Comment button.

Who sees what is decided by Clips, as before. When a Clip's comments are off, Clips' list answers only its owner, so other readers see no cards or markers. The owner sees the cards read-only, with no Comment button or Resolve, since Clips refuses every write while comments are off. The Comment button shows only when Clips' playback answer says the reader may comment and the reader can comment on the page.

What changed

  • templates/clips/server/lib/content-clip-peer.ts: one Clips change, on the existing comment list route. GET …/comments?view=open-threads returns open threads only, each with its text cut to 2,000 characters instead of the 280 a marker preview gets, its createdAt, its replyCount, and its replies oldest first. Replies are capped at 500 across the list, taken from the earliest thread on. It keeps the route's access check, its order, and the rule that a signed-out reader gets no commenter's email. Without view, the route answers exactly as before. The comment-length and timestamp limits it already enforced on writes moved into the shared contract (shared/content-clip-contract.ts) so Content validates against the same numbers.
  • templates/content/actions/: add-clip-comment (new; it takes a moment given as text, as the CLI passes it, and refuses a null or blank one rather than reading it as 0:00), resolve-clip-comment (new), and list-clip-comments, which now asks for the open-threads view and returns { threads, truncated } instead of markers. All three are agent tools now. _clip-embed-access.ts gains authorizeClipCommenter, which requires a signed-in reader with comment access to the page and fails with clip_comment_required otherwise.
  • templates/content/server/lib/clips-peer.ts: writeClipComment posts a write with the comment scope. requestClips and it share one request helper. The comment list's byte cap grows to cover the longest list the open-threads view can return.
  • clip-embed/use-clip-comment-writes.ts: add, reply and resolve show at once. Each write waits beside the Clip's comment list, not in it, and shows over every read of that list until Clips refuses it or a read begun after Clips took it lands. A read already on its way is cancelled first: otherwise the reread would join a first read that has no list yet, and that read could land after the save without the new comment. A new thread also stays while the list holds threads back and doesn't list it. A refusal takes back only that write, so a read that landed meanwhile keeps what it brought. use-clip-playback.ts lays the waiting writes over the list for the margin and the markers.
  • CommentsSidebar.tsx, DocumentEditor.tsx, comment-stacks.ts, CommentStack.tsx, comment-block-anchors.ts: Clip threads in the margin list, their quote line, their place in stacks, and the composer for a Clip moment. A folded stack fronting a Clip thread shows Clips' reply count, which counts replies the list held back. Text-comment paths are unchanged.
  • ClipEmbedBlock.tsx, ClipPlayer.tsx, player-controls.tsx, scrubber.tsx: the Comment button, markers that open cards, the playhead the folded stack follows, and the block's highlight while one of its threads is open.
  • discussion-panel.tsx and use-comments.ts: a thread on a Clip counts as being "in the text", not a page comment. The Discussion panel and its count don't include Clip threads.
  • Every new string in English and all ten configured locales, the document-editing skill, the Comments capability record (content.comment.page-owned) with an acceptance story, and the parity-matrix row for Clip embeds.
  • No changelog entry: the feature is behind a flag that is off. As in Play a pasted Clips link inside a Content page, with its comment markers #8147, its entry waits for A6, when the flag turns on.

Safety and operations

  • Rollout: behind content.clip-embed, off by default, evaluated on the server. With it off, the Comment button, the margin cards and all three actions are refused before Content reaches Clips.
  • Deploy order: Clips must run this PR before Content does, with the flag on. A Clips from before this PR ignores view=open-threads and answers without replies; this Content refuses that answer, and the player then says comments couldn't load, rather than showing threads as if no one had replied. With the flag off, nothing calls either route.
  • Permissions: no new trust path between Content and Clips, no new secret, and no new token scope. The comment scope and route already existed from Let Content document readers play and comment on Clips uploaded to the document #8090. Writes need page comment access in Content and whatever Clips already requires.
  • Data: no schema change and no migration. Content stores nothing new. Comments are written to Clips' existing recording_comments table through Clips' existing route.
  • Rollback: turn the flag off. Comments already posted stay in Clips, visible in Clips itself.

Verification

All of this is on 84d5c29 unless it says otherwise.

  • Tests, failing first: the new behavior tests failed before the change. CommentsSidebar.clip-comments.test.tsx covers the composer showing "At 0:06", the post going to add-clip-comment with videoTimestampMs: 6000, a 403 bringing the draft back, a quote click moving the video, replies, Resolve only for the author or owner, and read-only when Clips refuses comments. use-clip-comment-writes.test.tsx covers add, reply and resolve showing at once and being taken back, a waiting reply surviving a read that brings someone else's reply and then taking back only itself, a waiting thread and resolve surviving a read, and a saved write staying when the read after it fails. clip-comments.test.ts covers margin threads, who may resolve, and the thread the playhead last passed, including the first one before the playhead reaches any. ClipEmbedBlock.test.tsx covers the button, markers and seeking, and the button going away once Clips says comments are off. clip-embed-actions.test.ts covers the three actions' access checks. In Clips, content-clips.test.ts covers the open-threads view.
  • Checks run locally (Windows):
    • pnpm --filter content typecheck passes. pnpm --filter clips typecheck passed on 0c687f4, and no Clips file has changed since.
    • The clip-embed folder and CommentsSidebar.clip-comments.test.tsx pass on 84d5c29: 154 tests.
    • pnpm --filter content test: 9,459 passed and 2 failed, both outside this PR, and both pass when run alone: the 10k-document search budget in document-search-ranking.perf.test.ts (the machine was busy) and the GitHub folder freshness test in github-source.db.test.ts.
    • In Clips, content-clips.test.ts passes.
    • pnpm guards: 99 checks pass, including strict-lint, no-new-escape-hatches and no-silent-coercion. compiler-strictness could not run here because it can't read the Clips tsconfig on Windows, so CI is its check.
    • CI then caught two things, fixed on 22fa64e: the two new write actions now declare their MCP hints (neither is destructive; resolve is idempotent), and the new lines pass the strict compiler checks. Content's compiler with --noUncheckedIndexedAccess --exactOptionalPropertyTypes reports nothing on lines this branch adds, pnpm guard:strict-lint passes, Content typecheck passes, and the clip-embed and comments sidebar tests pass (394).
    • pnpm guard:i18n-catalogs and pnpm guard:i18n-changed-copy pass. The two new strings are in app/clip-embed-messages.ts, which the changed-copy guard doesn't read, so I checked by hand that comment and commentMoment are in English and all ten locales.
    • Fail only on Windows, so CI is their check: pnpm test:content-product-impact (its CLI tests spawn node_modules/.bin/tsx, a shell script) and the typecheck step of pnpm lint (spawn EINVAL).
  • Manual QA on local Content and Clips with made-up data (a 20-second test-pattern recording, dev@local.test), at 0683159, before the review fixes:
    • With the flag on, three Clip threads showed as cards reading "At 0:03", "At 0:10" and "At 0:15". With two or more beside the Clip they folded into one card, and playing from 0:15 back to 0:05 changed which comment was in front.
    • Clicking a card's moment moved the video to 0:05. Clicking a marker opened the stack on that thread with its reply box focused.
    • Comment opened the composer reading "At 0:05" before typing. The card and marker showed within 150 ms of posting, and reading back with list-clip-comments found the comment in Clips.
    • With comments turned off on the Clip in Clips and the request held 1.2 s, posting showed the card, then took it and its marker away, brought the draft back, and showed "Comments are turned off for this Clip".
    • A reply and a Resolve were both saved in Clips. Resolve removed the card and its marker at once.
    • With comments off, the owner still saw the cards, with no Comment button and no Resolve. A signed-out reader of the public page saw no comments. With comments on, that reader saw the scrubber marker and its preview, no Comment button and no margin.
    • A text comment and a comment on a plain video block behaved as before, and the Discussion count stayed at the page's 2 threads.
    • Enter on Comment focused the composer, and Escape with text asked to discard. At 820 px and 390 px wide, a marker or Comment opened the floating comment panel with the moment.
  • Rechecked on 84d5c29, since the review fixes rewrote the optimistic writes: a comment posted at 0:07 opened on top of the folded stack with its marker, and came back from Clips after a reload. With comments turned off in Clips and the save held 1.2 s, the card and marker showed 153 ms after the click, were taken back at 1,548 ms, and the draft came back.
  • Review-bot fixes on 0f3f7d4, each test failing first: a 502 on a new Clip comment gives the draft back (CommentsSidebar.clip-comments.test.tsx); a new thread survives a read begun before Clips took it that lands late, and reads that hold it back, then goes once a complete read doesn't list it (use-clip-comment-writes.test.tsx); a folded stack counts a Clip thread's replies from Clips (comment-stacks.test.ts); add-clip-comment takes "4200" and refuses null, "" and " " (clip-embed-actions.test.ts). Content typecheck passes; the touched suites pass (271 tests); pnpm guard:strict-lint, no-silent-coercion, no-oracle-loosening, no-source-reading-tests, no-new-escape-hatches, action-output-schema and no-bare-error-in-actions pass; the strict compiler check reports nothing on lines this branch adds. The full Content suite and manual QA weren't rerun for this round.
  • Clips (silent, real speed, made-up data; not uploaded; in the task's .tmp/a5-review-clips/): before, on main, a marker preview with an empty margin; after, commenting at 0:10, the stack following the playhead and a marker opening its card, and the rollback with the save held 1.2 s.
  • Independent model review (Sol, high), two rounds:
    • Round 1: took three of five findings: a list read landing while a write waited replaced it; a folded stack showed nothing before the playhead reached its first comment; Comment stayed after Clips said comments were off. Also tidied a test fixture and an unused constant. Declined two: a caller naming a recording the page doesn't embed (Clips' own permissions still decide, as agreed for Play a pasted Clips link inside a Content page, with its comment markers #8147), and no margin cards on public pages (public pages have no margin for text comments either; signed-out readers still get the markers and previews).
    • Round 2: confirmed the stack and Comment fixes. It found the first fix incomplete, because a saved write was dropped when the read after it failed. Fixed in 84d5c29. Its note that a capped reply list could count a saved reply twice is covered by that fix. Declined: a card can show twice for the moment between Clips saving a write and its answer arriving, if another read lands in between. It clears when the answer arrives, and a real fix needs an id Clips doesn't store.

Review focus

  • actions/_clip-embed-access.ts authorizeClipCommenter: can any path mint a comment token for a signed-out reader, or for a reader who can only view the page?
  • use-clip-comment-writes.ts: the waiting writes live in a module-level store keyed by page and Clip. Can one be left behind, shown twice, or dropped before Clips' list has it?
  • listCommentThreads / listThreadReplies in Clips' content-clip-peer.ts: the open-threads view must stay within the access check and bounds the old list had.
  • CommentsSidebar.tsx: the Clip paths should leave text comments, plain-video comments and stacks unchanged.

Known limits and follow-ups

  • Only open threads are listed. A resolved Clip thread can't be reopened from the page; it can in Clips.
  • Resolve is offered to the thread's author and the Clip's owner. Clips also lets the Clip's editors resolve, but Content doesn't know who they are, so it doesn't offer them the button.
  • Clip threads aren't in the Discussion panel. A Clip placed twice in one page shows its threads once, beside the first.
  • Mentions in a Clip comment are saved as plain text and notify no one, although the composer still offers @. Ask AI isn't offered on Clip threads.
  • When a list holds back replies (more than 500 across the Clip), a folded stack counts them all, but an open card shows only the ones the list brought and doesn't say more exist. A reply the reader posts on such a thread counts, but its text doesn't show once Clips' list arrives. Text past 2,000 characters is cut in the margin.
  • A new thread past the 500 threads the list returns stays shown until a read lists it or holds nothing back, so if someone resolves it in Clips meanwhile, it stays until the page reloads.
  • If posting a Clip comment times out after Clips saved it, the draft comes back, and posting it again saves it twice. Preventing that needs Clips to store an id for each write and answer a repeat with the first save, which is a change to Clips' comments table. Left for a follow-up.
  • A Clip that fails to play can't place its cards.
  • A new card can show twice for an instant if another read of the Clip's list lands after Clips saved it but before Clips' answer arrives. It clears when the answer arrives.
  • Out of scope, found during QA and present on main too: Tab stops on the player's volume slider and doesn't move on. After a comment draft is discarded, focus goes to the page body rather than back to the button that opened it (text comments do the same).
  • A6: duplicate, Trash, permanent delete, and turning the flag on.
content_product_impact:
  lane: contract_fulfillment
  features:
    - content.feature.collaborate-in-context
  capabilities:
    - content.comment.page-owned
  record_change: included
  proof:
    - pnpm --filter content test
    - pnpm --filter content typecheck
    - pnpm --filter clips typecheck
    - pnpm test:content-product-impact
  rationale: A Clip placed in a Page now shows its own comments, saved in Clips, as margin cards beside the Page's threads, and a reader can comment at a moment of its video. The Comments record gains that exception to Page ownership, an acceptance story, and its evidence.

🤖 Generated with Claude Code

A Clip block's comments, kept in Clips, now show as cards in the page's
comment margin at their moment of the video. Readers can comment at the
playhead, reply, and resolve through new Content actions that use the
existing Clips peer route. Behind the content.clip-embed flag.
A read of a Clip's comment list that landed while a write waited replaced the
optimistic entry, and a refusal then took back what the read brought. Writes
now wait beside the list and show over every read until Clips refuses them or
the read after Clips took them lands.

Also from review: a folded Clip stack shows its first comment before the
playhead reaches any; Comment goes away once Clips says comments are off; the
open-threads fixture matches the contract's bounds; strict-lint and
escape-hatch findings on added lines.
A write Clips took was dropped once the reread settled, even when that read
failed or never ran, so a saved comment vanished and a resolved thread came
back. It now stays until a read of the list made after Clips took it lands.
@github-actions

Copy link
Copy Markdown
Contributor

Here's a visual recap of what changed:

Visual recap

Open the full interactive recap

@builder-io-integration builder-io-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Builder reviewed your changes and found 5 potential issues 🔴

Review Details

Review summary

PR #8377 adds Clip comment threads to Content’s page margin, adds moment-based commenting/replies/resolution actions, and expands the Clips peer route to return bounded open threads and replies. The architecture appropriately keeps comment data in Clips, checks Content page roles before writes, and retains Clips as the authority for comment settings and per-thread resolution. The implementation also adds useful contract and optimistic-update coverage. This is high risk because it adds cross-service comment mutations and authorization paths.

Key findings

  • 🔴 HIGH: A 502/ambiguous failure posting a new Clip thread can permanently discard the user’s draft; retries also lack durable idempotency.
  • 🟡 MEDIUM: Optimistic entries can be cleared by a stale or bounded list response; replyCount is lost in the margin; null timestamps coerce to 0; and reply counting can scan all comments for a recording.
  • 🟢 The pending-thread reply race and unmarked 2,000-character truncation are lower-severity usability issues.

🧪 Browser testing: Skipped — the browser-test-planner agent is unavailable in this environment (the dev server is running).

}),
mentions: mentionsJsonFor(pendingText, pendingMentions, agent),
});
const result = clip

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Ambiguous Clip create failures discard the submitted draft

A Clips timeout/unreachable response is a 502, which this shared catch classifies as ambiguous and therefore skips restoreSubmittedDraft; finishSubmission then removes the only draft snapshot, while the Clip write hook drops its optimistic entry. Since the write also has no durable idempotency key, the user loses the text on failure and a retry after an actually committed timeout could instead create a duplicate; preserve/reconcile the draft and make Clip writes idempotent.

Fix in Builder

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0f3f7d4: a Clip comment that fails to post now gives its draft back on any failure, a 502 included. A text comment that may have been saved keeps a card that reconciles it; a Clip comment has no such card, so the draft is the only copy. Test: gives the draft back when Clips answers 502.

The duplicate-on-retry half isn't fixed here. Clips stores no id per write, so it can't tell a retry from a new comment, and teaching it to means a change to Clips' recording_comments table, which this PR stays out of. It's listed under the PR's known limits as a follow-up.

Comment thread templates/content/app/components/editor/clip-embed/use-clip-comment-writes.ts Outdated
Comment thread templates/content/app/components/editor/clip-embed/clip-comments.ts
Comment thread templates/content/actions/add-clip-comment.ts Outdated
Comment on lines +710 to +713
getDb()
.select({ threadId: comments.threadId, replies: count() })
.from(comments)
.where(inThreads)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Reply counts scan all comments for the recording on every list request

This count query filters by recording_id and thread_id, but the existing index only covers (recording_id, created_at), so PostgreSQL must scan the recording’s comments to count replies for the listed threads. The scan runs on each open-threads request and grows with the full comment history; add an index suited to (recording_id, thread_id) or otherwise avoid scanning unrelated comments.

Fix in Builder

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declining this one in this PR. The count reads one recording's comments only: recording_id leads the existing (recording_id, created_at) index, so the scan is bounded by that Clip's history, not the table. An index on (recording_id, thread_id) would be a new numbered Clips migration, building an index on a production table at startup, and this PR changes no Clips schema. If a Clip with a long comment history reads slowly, that index is the follow-up.

builder-io-integration[bot]

This comment was marked as outdated.

- A Clip comment that fails to post gives its draft back, a timeout
  included: no card stays behind to reconcile it the way text comments do.
- A saved write waits for a list read begun after Clips took it. A read
  already on its way is cancelled first, since the reread would otherwise
  join a first read that has no list yet.
- A new thread stays shown while the list holds threads back and doesn't
  list it.
- Folded stacks show Clips' reply count, which counts replies the list
  held back.
- add-clip-comment refuses a null or blank videoTimestampMs instead of
  reading it as 0:00.

@builder-io-integration builder-io-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Builder reviewed your changes — no new findings

Review Details

Incremental review summary

The PR head advanced from 22fa64e1 to 0f3f7d4b (+183/−17 across 12 files). This delta rejects null/blank timestamps, restores a Clip comment draft on ambiguous failures, preserves the server’s reply count in margin cards, and changes optimistic-write reconciliation to cancel prior reads and retain omitted new threads from truncated lists. The timestamp and reply-count fixes address two previously reported issues, and I resolved those review threads. Two independent code-review agents found no new issue in this delta.

This remains a high-risk PR because it adds cross-service comment mutations. Existing comments remain open for duplicate risk after ambiguous writes (the draft is now restored, but no idempotency key is added), bounded reply reconciliation, and the per-recording reply-count scan. The bounded-read concern still applies to replies omitted from the capped reply list; the new settlement check only verifies presence for new threads.

🧪 Browser testing: Skipped — the browser-test-planner agent is unavailable in this environment.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants