PT-4029: feat(comments): resolveConflict (accept/reject) write backend for verseText merge conflicts#2495
Conversation
86380a8 to
0773ead
Compare
…ata provider Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JU63vyD4mRXpEGX5KKdyuB
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JU63vyD4mRXpEGX5KKdyuB
…d resolves Two hardening fixes to ResolveConflict, plus doc completeness: - Reject resolving an already-resolved thread. Without this, reject-after-accept passed every guard and rewrote the verse of a settled conflict - a transition PT9's UI never allows. Throw InvalidOperationException before the permission gate when thread.Status == Resolved. - Surface a canceled resolve. CommentEditHelper.SaveEdits returns false when the creator-resolve is canceled; the return was ignored, so we would fire "resolved" events and report success on a still-open thread. Capture it and throw before firing any events. Docs: complete the C# XML <exception> list (already-resolved, canceled) and add a <remarks> note that the reject verse write is best-effort inside PT9's SaveEdits (ReplaceAcceptedText silently no-ops if the verse marker is missing). Complete the TS resolveConflict @throws list (invalid resolution, already-resolved). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JU63vyD4mRXpEGX5KKdyuB
…t, strict accept
Broaden ResolveConflict coverage per spec section 9 and the review findings:
- Event firing: accept fires a comment data-update and NOT a Scripture data-update;
reject fires both. Asserts on the data types each event carries (comment vs
Scripture), using drain-then-poll (SpinWait, no bare sleeps) so it stays robust
even though the harness dispatches SendDataUpdateEvent synchronously.
- Already-resolved guard: reject-after-accept throws and does not rewrite the verse.
- Non-admin reject (NN4 primary flow): the assignee-succeeds test now uses "reject"
and asserts the loser text was written, exercising SaveEdits' chapter-edit path
under the non-admin PermissionManager fixture (previously only accept ran).
- Most-recent-assignment-wins: a conflict whose later comment reassigns to a
different user is denied for a non-admin, even though an earlier comment matched.
- Strict accept assertion: assert the verse is byte-for-byte the seeded winner
(captured from a throwaway project to avoid the mid-sequence CommentManager
desync), not just Contains("big village").
- De-duplicate seeding: the instance SeedVerseTextConflict() now delegates to the
static overload.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JU63vyD4mRXpEGX5KKdyuB
0773ead to
dc9e8f6
Compare
…sion (PT-4107) Adding a plain comment to a resolved thread implicitly re-opens it (PT-3524, #1935), but that path skipped the VerifyUserCanResolveThread gate the explicit status change enforces. A user who can comment but not resolve could therefore re-open a thread by replying, bypassing the gate (e.g. lacking spelling/BT edit rights, or a non-creator on a creator-resolve thread). Require the same gate whenever a reply would re-open a resolved thread. PT9 anchor: PT9 never auto-reopens on a reply at all — reopening is only ever the explicit, permission-gated Unresolve action (CommentEditorForm, gated by CanCurrentUserResolve). We keep PT10's reply-reopens-thread UX but close the hole. Test: a non-admin lacking Spellings edit permission (so CanCurrentUserResolve on a spelling note is false) is blocked from re-opening a resolved thread by replying. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014CKj4FJgk9NrFtPioQbiGD
…mments Address code-review findings on the resolveConflict permission flow (PR #2495): - IsThreadAssignedToCurrentUser now treats the "Team" assignment sentinel (CommentThread.teamUser) as assign-to-everyone, so any non-admin team member can resolve a team-assigned conflict instead of being wrongly denied by the admin-or-assignee gate. Adds ResolveConflict_NonAdminTeamAssigned_Allowed. - Replace opaque "NN4" / "spec section 9" spec tags in code and test comments with plain-English descriptions (decipherable-initialisms convention). - Drop citations of the untracked scratch file task-1-report.md from test comments; state the transient-desync rationale inline instead. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AzT8SMKLTiNFJJ6Rjwg6LC
… staleness guard Factor the ResolveConflict gate into VerifyUserCanResolveConflict (shared by enforcement and the new capability query), add IsConflictVerseStale mirroring PT9's CommentHtmlBuilder.GetResolutionOptions, add the never-throwing getConflictResolutionOptions capability (wire: getConflictResolutionOptions), and guard reject against a stale (post-merge-edited) verse. Declare the TS contract (getConflictResolutionOptions + ConflictResolutionOptions union). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JU63vyD4mRXpEGX5KKdyuB
…Conflict
AddCommentToThread({status: Resolved}) on a Conflict thread now throws
InvalidOperationException directing callers to resolveConflict, closing the
bypass that would mark a conflict resolved without applying accept/reject and
then lock out the real flow via the already-resolved guard. Status Todo
(reopen) and Normal threads are unaffected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JU63vyD4mRXpEGX5KKdyuB
…se branch Add two GetConflictResolutionOptions edge-case tests around the existing staleness coverage, both test-only (no product change): - WhitespaceOnlyEdit_StillAcceptOrReject: re-saving the winner verse with only interior whitespace differences stays "acceptOrReject", pinning the deliberate PT9 divergence where IsConflictVerseStale RegularizeSpaces() .Trim()'s both the current and recorded verse USFM. - UnreadableVerse_AcceptOnly: when MAT 2:1's verse marker is gone the verse can't be read, exercising IsConflictVerseStale's invalid/unreadable-verse branch, so only "accept" remains. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JU63vyD4mRXpEGX5KKdyuB
…le-gated The <remarks> said "we do not add compensating logic" for the ReplaceAcceptedText no-op, but IsConflictVerseStale now pre-gates reject and refuses it when the verse is missing or changed, so that no-op path is unreachable. Update the remark to match. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AzT8SMKLTiNFJJ6Rjwg6LC
…race ResolveConflict checked "already resolved" (in VerifyUserCanResolveConflict) and then applied the resolution via SaveEdits with no lock between, so two concurrent resolveConflict calls on the same thread could both pass the guard and both splice the loser text, corrupting an already-settled conflict. Wrap the guard+apply in a per-PDP (per-project) lock so the guard is atomic with the write: exactly one call resolves, the rest hit the already-resolved guard. Matches the C# concurrency guideline for multi-step shared-state updates. Adds ResolveConflict_ConcurrentRejects_ApplyExactlyOnce (8 racers; verified it fails without the lock and passes with it). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AzT8SMKLTiNFJJ6Rjwg6LC
Add a verseText merge-conflict fixture whose two sides edit independent, non-overlapping words so CommentEditHelper.GetMergedUsfm returns non-null (PT9's "Merge all changes" signal). Existing fixtures edit the same word, producing overlapping diffs and a null merge. - CreateIndependentVerseTextConflictComment(): rejected inserts "small " before "village" (early word); accepted inserts "great " before "king" (late word). Merged verse contains both edits. - SeedIndependentVerseTextConflict(ScrText) + MatTwoIndependentWinnerUsfm. - IndependentConflictFixture_IsMergeable test asserts merge is non-null. Test-only; no production code changed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AzT8SMKLTiNFJJ6Rjwg6LC
…ions Return "acceptRejectOrMerge" when CommentEditHelper.GetMergedUsfm(thread) is non-null (independent, non-overlapping edits that PT9 can actually merge), matching the gate PT9's GetResolutionOptions uses. Stale conflicts still return "accept"; overlapping edits still return "acceptOrReject". Adds two tests covering the independent-changes and overlapping-changes branches, using the SeedIndependentVerseTextConflict/SeedVerseTextConflict fixtures from the prior commit.
Allows resolution == "merge" through validation, extends the staleness guard (previously reject-only) to cover merge as well, maps merge to NoteConflictResolutions.Merged, and fires the Scripture-update event on merge. The verse write itself is already handled by the existing CommentEditHelper.SaveEdits call (PT9 dispatches Merged -> MergeAcceptedText -> GetMergedUsfm); no merge algorithm is authored here. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AzT8SMKLTiNFJJ6Rjwg6LC
Adds MergedText to PlatformCommentWrapper, computed entirely from existing Paratext.Data calls (CommentEditHelper.GetMergedUsfm + GetDiffVerseUsfm(base) + DiffToken.GetDiffString), mirroring PT9's CommentHtmlBuilderUI merge-render path exactly and rendered through the file's existing RenderConflictSideHtml helper. Null when merge is unavailable (overlapping edits) or thread context is missing. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AzT8SMKLTiNFJJ6Rjwg6LC
MergedText was added to PlatformCommentWrapper but never wired into Write(), so the merge-diff preview never reached the frontend. Add the mergedText key constant and a TryWriteString call mirroring the sibling conflict fields (rejectedText/acceptedText/resultText/rejectedResultText), plus JSON assertions on the existing independent/overlapping MergedText tests confirming the key is present only for independent-changes conflicts.
…-manager types Extends the legacy-comment-manager TypeScript contract so the frontend can call the merge resolution added on the C# side (A1-A3): - resolveConflict's resolution union gains 'merge'. - ConflictResolutionOptions gains 'acceptRejectOrMerge'. - LegacyComment (platform-bible-utils) gains the mergedText field that PlatformCommentConverter now serializes on the wire, plus its name in the two NewLegacyComment/LegacyCommentReply field-omission unions. - TSDoc updated to describe the new resolution/options and their throw conditions. Regenerated lib/platform-bible-utils/dist/index.d.ts (type-only change, no runtime output difference) so the field is visible to consumers resolving platform-bible-utils via its published types. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AzT8SMKLTiNFJJ6Rjwg6LC
The stale guard message built "cannot be {resolution}ed", yielding "cannot be
mergeed" for resolution='merge'. Reword to "cannot be resolved with '{resolution}'"
so it reads correctly for both reject and merge.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AzT8SMKLTiNFJJ6Rjwg6LC
- Refuse resolveConflict "merge" on overlapping edits (GetMergedUsfm null) — it was splicing null into the chapter USFM and silently erasing the verse - Require chapter CanEdit for non-admin conflict resolvers (match PT9) so a non-admin assignee can't write to a chapter they aren't permitted to edit - Hoist ConflictResolutionOptions into platform-bible-utils as the single source of truth; re-export it from legacy-comment-manager - Rename IsThreadAssignedToCurrentUser -> IsThreadAssignedToUser - Align "thread does not exist" exception-message punctuation - Add C# tests for merge-on-overlapping and non-admin-without-chapter-edit denial Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Session-URL: https://claude.ai/code/session_015hfnmegSuB5vVmfNCvAtqk
Resolve conflicts (all keep-both): - lib/platform-bible-utils/src/comments.types.ts — keep pt-4029's new `mergedText` field and `ConflictResolutionOptions` type alongside main's FIRST->ROOT comment doc rewording. - c-sharp-tests/CommentTestHelper.cs — keep both new fixtures: `CreateIndependentVerseTextConflictComment` (pt-4029) and `CreateVerseTextConflictCommentReplacementBothSides` (main). - lib/platform-bible-utils/dist/index.d.ts — regenerated from the merged source. Verified: platform-bible-utils typecheck + build clean; c-sharp-tests 1378 pass. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Session-URL: https://claude.ai/code/session_015hfnmegSuB5vVmfNCvAtqk
lyonsil
left a comment
There was a problem hiding this comment.
AI-assisted code review (Claude Opus 4.8, max-effort recall pass) of the resolveConflict / getConflictResolutionOptions write backend. 14 inline findings below, most-severe first: 1 confirmed regression (non-verseText conflicts unresolvable), a one-sided lock, and a MergedText serialization throw that can drop the whole thread list — plus correctness edges, reuse/simplification, and one borderline convention nit. Grounded against the PT9 CommentEditHelper / CommentThread sources and the PR's own test fixtures. Non-blocking (COMMENT event).
|
Regarding the review items:
|
Resolves the 14 findings from the follow-up review on #2495 (all against the current tip; several verified against the PT9 ParatextData source). Correctness / robustness: - Resolve-via-status guard now fires only for verseText conflicts. Gating on Type == Conflict blocked every other conflict type (invalidVerses, readError, verseBridge, ...) from being marked resolved through any API path. + test. - Contain a MergedText serialization throw in PlatformCommentConverter.Write so one malformed conflict can no longer drop the entire getCommentThreads response. - Guard the empty-Comments access (PT9's CommentThread.FirstComment is private). - MergedText base now diffs against the same comment GetMergedUsfm uses (Comments[0]), not the root, avoiding a garbled preview when they differ. - Log unexpected errors in GetConflictResolutionOptions / IsConflictVerseStale instead of silently degrading; expected domain outcomes stay quiet. Concurrency: - Serialize all comment mutations (ResolveConflict, AddCommentToThread, CreateComment, UpdateComment, DeleteComment) under a single _commentMutationLock so their read-modify-write of the shared CommentManager is atomic. The previous lock was resolve-only. PT9 is single-threaded, so this concurrency is new in PT10. + concurrency test. Cleanup: - VerifyUserCanResolveConflict returns the verified thread, removing two redundant re-finds and two dead null-checks; extract writesVerse. - Pin the intentional divergences from PT9 CommentHtmlBuilder.GetResolutionOptions. - Characterization test pinning PT9's reject-on-whole-verse-deletion write path (faithful delegation to ReplaceAcceptedText; a guard would diverge from PT9). - Convert PR-introduced em dashes to hyphens. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VuDwiegLDT9u7dD7k3YrB
Brings the #2495 second-pass review fixes into the pt-4030 card line. The only conflict was the C# test file, where both branches appended tests; resolved by keeping all three (pt-4030's resolution-action tests + pt-4029's concurrency test). 145 C# comment/converter tests pass after the merge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VuDwiegLDT9u7dD7k3YrB
Drop self-referential PR/ticket tags (PT-4029 review, PT-4107) from code and test comments, keeping the explanatory prose. Ticket context belongs in the PR/commit, not the source. Comment-only; no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017vhggZ6RR1ctmvMK7akLdW
… comment Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017vhggZ6RR1ctmvMK7akLdW
CommentManager.Load() rebuilds its comment list solely from
FileManager.ProjectFiles("Notes_*.xml"). The dummy file manager's ProjectFiles
was a stub returning nothing, so any reload (triggered in the full test
assembly by a WriteLockManager notification, e.g. the post-seed verse edit)
rebuilt an empty comment list and dropped seeded comments. This made the
verseText-conflict tests flake ~2.5-5% of full-assembly runs with
"Thread with id 5f5ea40f does not exist" (masked to "none" in the
GetConflictResolutionOptions path); they passed in isolation because the
in-memory comment list still held the comment.
Implement ProjectFiles to return the in-memory files whose name matches the
glob within relDirPath, and add GetConflictResolutionOptions_SurvivesComment-
ManagerReload, which forces a reload and asserts the seeded conflict thread
survives (fails without the fix, returning "none").
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwRALJ5UQZUvoNGew1He6F
lyonsil
left a comment
There was a problem hiding this comment.
@lyonsil reviewed 11 files and all commit messages, made 1 comment, and resolved 14 discussions.
Reviewable status:complete! all files reviewed, all discussions resolved.
…list with live resolve actions (#2497) * feat(comments): add resolveConflict (accept/reject) to the comments data provider Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JU63vyD4mRXpEGX5KKdyuB * test(comments): resolveConflict permission gate (admin/assignee/denied) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JU63vyD4mRXpEGX5KKdyuB * feat(comments): declare resolveConflict on the legacy comment PDP interface * fix(comments): guard resolveConflict against re-resolving and canceled resolves Two hardening fixes to ResolveConflict, plus doc completeness: - Reject resolving an already-resolved thread. Without this, reject-after-accept passed every guard and rewrote the verse of a settled conflict - a transition PT9's UI never allows. Throw InvalidOperationException before the permission gate when thread.Status == Resolved. - Surface a canceled resolve. CommentEditHelper.SaveEdits returns false when the creator-resolve is canceled; the return was ignored, so we would fire "resolved" events and report success on a still-open thread. Capture it and throw before firing any events. Docs: complete the C# XML <exception> list (already-resolved, canceled) and add a <remarks> note that the reject verse write is best-effort inside PT9's SaveEdits (ReplaceAcceptedText silently no-ops if the verse marker is missing). Complete the TS resolveConflict @throws list (invalid resolution, already-resolved). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JU63vyD4mRXpEGX5KKdyuB * test(comments): resolveConflict events, non-admin reject, reassignment, strict accept Broaden ResolveConflict coverage per spec section 9 and the review findings: - Event firing: accept fires a comment data-update and NOT a Scripture data-update; reject fires both. Asserts on the data types each event carries (comment vs Scripture), using drain-then-poll (SpinWait, no bare sleeps) so it stays robust even though the harness dispatches SendDataUpdateEvent synchronously. - Already-resolved guard: reject-after-accept throws and does not rewrite the verse. - Non-admin reject (NN4 primary flow): the assignee-succeeds test now uses "reject" and asserts the loser text was written, exercising SaveEdits' chapter-edit path under the non-admin PermissionManager fixture (previously only accept ran). - Most-recent-assignment-wins: a conflict whose later comment reassigns to a different user is denied for a non-admin, even though an earlier comment matched. - Strict accept assertion: assert the verse is byte-for-byte the seeded winner (captured from a throwaway project to avoid the mid-sequence CommentManager desync), not just Contains("big village"). - De-duplicate seeding: the instance SeedVerseTextConflict() now delegates to the static overload. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JU63vyD4mRXpEGX5KKdyuB * fix(comments): gate implicit thread re-open behind the resolve permission (PT-4107) Adding a plain comment to a resolved thread implicitly re-opens it (PT-3524, #1935), but that path skipped the VerifyUserCanResolveThread gate the explicit status change enforces. A user who can comment but not resolve could therefore re-open a thread by replying, bypassing the gate (e.g. lacking spelling/BT edit rights, or a non-creator on a creator-resolve thread). Require the same gate whenever a reply would re-open a resolved thread. PT9 anchor: PT9 never auto-reopens on a reply at all — reopening is only ever the explicit, permission-gated Unresolve action (CommentEditorForm, gated by CanCurrentUserResolve). We keep PT10's reply-reopens-thread UX but close the hole. Test: a non-admin lacking Spellings edit permission (so CanCurrentUserResolve on a spelling note is false) is blocked from re-opening a resolved thread by replying. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014CKj4FJgk9NrFtPioQbiGD * fix(comments): allow team-assigned resolvers + decipherable review comments Address code-review findings on the resolveConflict permission flow (PR #2495): - IsThreadAssignedToCurrentUser now treats the "Team" assignment sentinel (CommentThread.teamUser) as assign-to-everyone, so any non-admin team member can resolve a team-assigned conflict instead of being wrongly denied by the admin-or-assignee gate. Adds ResolveConflict_NonAdminTeamAssigned_Allowed. - Replace opaque "NN4" / "spec section 9" spec tags in code and test comments with plain-English descriptions (decipherable-initialisms convention). - Drop citations of the untracked scratch file task-1-report.md from test comments; state the transient-desync rationale inline instead. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AzT8SMKLTiNFJJ6Rjwg6LC * feat(comments): getConflictResolutionOptions capability + reject-only staleness guard Factor the ResolveConflict gate into VerifyUserCanResolveConflict (shared by enforcement and the new capability query), add IsConflictVerseStale mirroring PT9's CommentHtmlBuilder.GetResolutionOptions, add the never-throwing getConflictResolutionOptions capability (wire: getConflictResolutionOptions), and guard reject against a stale (post-merge-edited) verse. Declare the TS contract (getConflictResolutionOptions + ConflictResolutionOptions union). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JU63vyD4mRXpEGX5KKdyuB * feat(comments): route conflict resolution exclusively through resolveConflict AddCommentToThread({status: Resolved}) on a Conflict thread now throws InvalidOperationException directing callers to resolveConflict, closing the bypass that would mark a conflict resolved without applying accept/reject and then lock out the real flow via the already-resolved guard. Status Todo (reopen) and Normal threads are unaffected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JU63vyD4mRXpEGX5KKdyuB * test(comments): pin staleness whitespace tolerance and unreadable-verse branch Add two GetConflictResolutionOptions edge-case tests around the existing staleness coverage, both test-only (no product change): - WhitespaceOnlyEdit_StillAcceptOrReject: re-saving the winner verse with only interior whitespace differences stays "acceptOrReject", pinning the deliberate PT9 divergence where IsConflictVerseStale RegularizeSpaces() .Trim()'s both the current and recorded verse USFM. - UnreadableVerse_AcceptOnly: when MAT 2:1's verse marker is gone the verse can't be read, exercising IsConflictVerseStale's invalid/unreadable-verse branch, so only "accept" remains. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JU63vyD4mRXpEGX5KKdyuB * docs(comments): correct ResolveConflict remark now that reject is stale-gated The <remarks> said "we do not add compensating logic" for the ReplaceAcceptedText no-op, but IsConflictVerseStale now pre-gates reject and refuses it when the verse is missing or changed, so that no-op path is unreachable. Update the remark to match. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AzT8SMKLTiNFJJ6Rjwg6LC * fix(comments): serialize resolveConflict to close the check-then-act race ResolveConflict checked "already resolved" (in VerifyUserCanResolveConflict) and then applied the resolution via SaveEdits with no lock between, so two concurrent resolveConflict calls on the same thread could both pass the guard and both splice the loser text, corrupting an already-settled conflict. Wrap the guard+apply in a per-PDP (per-project) lock so the guard is atomic with the write: exactly one call resolves, the rest hit the already-resolved guard. Matches the C# concurrency guideline for multi-step shared-state updates. Adds ResolveConflict_ConcurrentRejects_ApplyExactlyOnce (8 racers; verified it fails without the lock and passes with it). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AzT8SMKLTiNFJJ6Rjwg6LC * test(comments): add independent-changes conflict fixture (mergeable) Add a verseText merge-conflict fixture whose two sides edit independent, non-overlapping words so CommentEditHelper.GetMergedUsfm returns non-null (PT9's "Merge all changes" signal). Existing fixtures edit the same word, producing overlapping diffs and a null merge. - CreateIndependentVerseTextConflictComment(): rejected inserts "small " before "village" (early word); accepted inserts "great " before "king" (late word). Merged verse contains both edits. - SeedIndependentVerseTextConflict(ScrText) + MatTwoIndependentWinnerUsfm. - IndependentConflictFixture_IsMergeable test asserts merge is non-null. Test-only; no production code changed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AzT8SMKLTiNFJJ6Rjwg6LC * feat(comments): report merge availability in GetConflictResolutionOptions Return "acceptRejectOrMerge" when CommentEditHelper.GetMergedUsfm(thread) is non-null (independent, non-overlapping edits that PT9 can actually merge), matching the gate PT9's GetResolutionOptions uses. Stale conflicts still return "accept"; overlapping edits still return "acceptOrReject". Adds two tests covering the independent-changes and overlapping-changes branches, using the SeedIndependentVerseTextConflict/SeedVerseTextConflict fixtures from the prior commit. * feat(comments): support 'merge' resolution in ResolveConflict Allows resolution == "merge" through validation, extends the staleness guard (previously reject-only) to cover merge as well, maps merge to NoteConflictResolutions.Merged, and fires the Scripture-update event on merge. The verse write itself is already handled by the existing CommentEditHelper.SaveEdits call (PT9 dispatches Merged -> MergeAcceptedText -> GetMergedUsfm); no merge algorithm is authored here. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AzT8SMKLTiNFJJ6Rjwg6LC * feat(comments): serialize PT9's merge-diff preview as MergedText Adds MergedText to PlatformCommentWrapper, computed entirely from existing Paratext.Data calls (CommentEditHelper.GetMergedUsfm + GetDiffVerseUsfm(base) + DiffToken.GetDiffString), mirroring PT9's CommentHtmlBuilderUI merge-render path exactly and rendered through the file's existing RenderConflictSideHtml helper. Null when merge is unavailable (overlapping edits) or thread context is missing. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AzT8SMKLTiNFJJ6Rjwg6LC * feat(comments): serialize MergedText as mergedText JSON field MergedText was added to PlatformCommentWrapper but never wired into Write(), so the merge-diff preview never reached the frontend. Add the mergedText key constant and a TryWriteString call mirroring the sibling conflict fields (rejectedText/acceptedText/resultText/rejectedResultText), plus JSON assertions on the existing independent/overlapping MergedText tests confirming the key is present only for independent-changes conflicts. * feat(comments): declare 'merge' conflict resolution in legacy-comment-manager types Extends the legacy-comment-manager TypeScript contract so the frontend can call the merge resolution added on the C# side (A1-A3): - resolveConflict's resolution union gains 'merge'. - ConflictResolutionOptions gains 'acceptRejectOrMerge'. - LegacyComment (platform-bible-utils) gains the mergedText field that PlatformCommentConverter now serializes on the wire, plus its name in the two NewLegacyComment/LegacyCommentReply field-omission unions. - TSDoc updated to describe the new resolution/options and their throw conditions. Regenerated lib/platform-bible-utils/dist/index.d.ts (type-only change, no runtime output difference) so the field is visible to consumers resolving platform-bible-utils via its published types. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AzT8SMKLTiNFJJ6Rjwg6LC * fix(comments): correct stale-resolution error grammar for merge The stale guard message built "cannot be {resolution}ed", yielding "cannot be mergeed" for resolution='merge'. Reword to "cannot be resolved with '{resolution}'" so it reads correctly for both reject and merge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AzT8SMKLTiNFJJ6Rjwg6LC * test(comments): add real-valued verseText conflict sample for the card * feat(comments): add presentational ConflictNoteCard with dynamic result preview * style(comments): prettier-format the conflict sample JSDoc * refactor(comments): render conflict card for no-ancestor case (match PT9) Gate the structured verseText render on resultText presence (not acceptedText), matching PT9's per-block conditional. When acceptedText is absent (no common ancestor conflict), the Accepted region is omitted but the selector, Rejected region, and Result preview all remain functional. Adds a test for this case. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Ey9TG13YAyRhgqmKEk8BC * test(comments): cover onResolutionChange + read-only result; align choose aria-label Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Ey9TG13YAyRhgqmKEk8BC * docs(comments): Storybook story for ConflictNoteCard (default, no-ancestor, restricted) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Ey9TG13YAyRhgqmKEk8BC * chore(comments): export ConflictNoteCard from barrel + gate consistency Address final-review minors: - Export ConflictNoteCard, CONFLICT_NOTE_STRING_KEYS, and its prop types from platform-bible-react's index.ts so the extension can consume the card and pass the string keys to useLocalizedStrings (unblocks PT-4030 integration). - Gate the Accepted region on `!!acceptedText` (was `!== undefined`) for consistency with the resultText gate and empty-string safety. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style(comments): PT9 RedGreen diff colors + normal Result tone - Diff highlight now matches PT9's default RedGreen style (by insertion/deletion, same in both regions): inserted text green (text-success-foreground) + bold, deleted text red (text-destructive) + strikethrough. Theme tokens only. - Result preview rendered in normal text-foreground (was muted) — it's the most important line ('how the verse will read'), so no longer de-emphasized. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(comments): add replacement-based conflict sample + <s>/<u> coverage Add CreateVerseTextConflictCommentWithReplacement fixture (town→village/city), a converter test that asserts both <s> (deletion) and <u> (insertion) appear in rejectedText/acceptedText, and the verbatim-captured verseTextConflictReplacementSample for the ConflictNoteCard Default story — making struck-red text visible in Storybook. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Ey9TG13YAyRhgqmKEk8BC * fix(comments): trim dangling strikethrough whitespace + honest diff-style comment - trimDiffSpanWhitespace: move each diff span's trailing space outside </s>/</u> so the strikethrough/color stops at the word instead of dangling into the inter-word gap. PT9 keeps the space inside the diff token and masks it with a background highlight; our text-only decoration would otherwise show a stray strike over the space. + regression test. - Correct the code comment: our green/red is a text-color treatment, NOT PT9's default (BlueGray) or RedGreen (both use background highlights; RedGreen has no strikethrough). Insertions now semibold (was medium, while the comment said bold). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(comments): show Accepted region before Rejected (match PT9 order) PT9's AppendConflictNoteDetails renders the accepted/current text first, then the rejected side. Leading with the winning text reads more naturally given Accept is the default resolution. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(ui): ConflictNoteCard availableActions + Resolve button + stale tooltip Replace the selection-only canAcceptReject boolean with a three-mode availableActions union ('none' | 'accept' | 'acceptOrReject'): 'none' hides the selector and Resolve button (read-only regions still render), 'accept' disables the Reject option behind a stale-verse explanation tooltip and forces the visible selection to accept, 'acceptOrReject' is fully available. Add a Resolve button that reports the current selection via onResolve, and an isResolving lock that disables both the selector and the button while a resolve call is in flight. Grow CONFLICT_NOTE_STRING_KEYS to 11 keys and add the ConflictResolutionOptions union (textually identical to legacy-comment-manager.d.ts). The card stays purely presentational. Update tests (4 new, replacing the canAcceptReject test) and stories (RestrictedPermissions -> availableActions none, new StaleVerse story). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JU63vyD4mRXpEGX5KKdyuB * feat(ui): render ConflictNoteCard for conflict threads with resolve flow Add a conflict branch to CommentThread: when a thread is a conflict (thread.type === 'Conflict') and selected, render ConflictNoteCard in place of the first CommentItem; when collapsed, keep the existing line-clamped CommentItem. Suppress the generic hover-check "Resolve thread" button for conflict threads (adds !isConflictThread to the gate) so conflicts are resolved only through the card. Fetch the available resolution actions via a new getConflictResolutionOptionsCallback (mirroring the canResolve effect, with threadStatus in deps so resolve/reopen re-queries). Route the card's Resolve click through handleResolveConflict; on success, immediately lock the controls by setting the local options to 'none' (survives until the data refresh flips threadStatus), preventing a double resolve during the stale-props window. The card's availableActions is forced to 'none' whenever threadStatus is 'Resolved', regardless of the fetched options. Thread handleResolveConflict and getConflictResolutionOptionsCallback (both optional; missing => read-only card via 'none') through CommentListProps and CommentThreadProps, pass them through at the CommentList <CommentThread> site, and export ConflictResolutionOptions from the barrel. Add CommentThread conflict-resolution tests (card-when-selected, plain-item-when-collapsed, hover-button suppression, resolve-flow lock). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JU63vyD4mRXpEGX5KKdyuB * feat(comments): wire conflict resolve flow into the comment list web view Add the production wiring for PT-4030's conflict-resolution UI in the legacy-comment-manager extension. In comment-list.web-view.tsx, add two withPdp-wrapped callbacks: getConflictResolutionOptionsCallback (memoized on [commentsPdp] only, so its stable identity doesn't re-run CommentThread's options effect and prematurely undo the post-success 'none' lock) defaulting to 'none' when the PDP is unavailable, and handleResolveConflict (memoized on [commentsPdp, localizedStrings]) which calls pdp.resolveConflict, returns true on success, and on failure logs, fires a sonner error toast, and returns false. Spread CONFLICT_NOTE_STRING_KEYS into the stable useLocalizedStrings key array, wrap the panel in a fragment, forward both new props to CommentListPanel, and mount a single <Sonner /> toaster sibling. (The shadcn sonner module exports the component as Sonner and the toast fn as sonner, so those names are used rather than Toaster/toast.) Forward handleResolveConflict and getConflictResolutionOptionsCallback through CommentListPanel: add both to its Pick<CommentListProps> union, destructure them, and pass them to <CommentList>. Add the 11 %conflict_note_*% strings to both the en and es maps in localizedStrings.json. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JU63vyD4mRXpEGX5KKdyuB * docs(ui): CommentList stories for unresolved and stale conflict threads * docs(ui): CommentThread story for a complete conflict thread Add comment-thread.stories.tsx with a stateful wrapper (mirroring CommentListStory) that renders a full verseText conflict thread: the captured conflict sample as the first comment, an assigned-to-current-user badge, hand-written discussion replies, a working reply editor, and an interactive Resolve that appends a resolution reply and locks the card (a mock of PT9 SaveEdits, not backend behavior). Adds ResolvedConflictThread (read-only card) and StaleConflictThread (Reject disabled) variants. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JU63vyD4mRXpEGX5KKdyuB * fix(ui): strengthen conflict suppression test, catch options rejections, guard resolve busy-state Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JU63vyD4mRXpEGX5KKdyuB * docs(ui): complete conflict thread in list stories, upgrade legacy conflict samples, Default thread anchor Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JU63vyD4mRXpEGX5KKdyuB * fix(ui): announce stale-reject reason to assistive tech The disabled Reject option's stale explanation was hover-tooltip-only, so screen readers navigating the options never heard why Reject was unavailable. Render the notice in a visually-hidden (sr-only) span with an id and point the disabled SelectItem's aria-describedby at it. Radix's Select.Item forwards aria-describedby onto its role="option" element, so the option gains an accessible description. The visual Tooltip is unchanged; both surfaces now share one notice string. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JU63vyD4mRXpEGX5KKdyuB * chore(ui): regenerate platform-bible-react dist for conflict-note exports Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HUx81dkkHqJveHhqJAHg1F * feat(comments): serialize ConflictResolutionAction on the comment wrapper Resolved conflict threads showed the wrong Result and an empty resolution reply because the backend never surfaced ParatextData's Comment.ConflictResolutionAction. Serialize it so the UI can tell reject ('replaced') / merged from accept (absent). - PlatformCommentWrapper: nullable ConflictResolutionAction pass-through, serialized UNGATED (the resolution comment is Type==Conflict but ConflictType==None, so the verseText gate would never fire for it). - PlatformCommentConverter: null-skip write of the camelCase key (serialize-only, no Read case). - LegacyComment TS type gains conflictResolutionAction?: string. - Tests: converter emits/omits the key ungated; ResolveConflict(reject) stamps 'replaced' on the resolution comment while accept leaves none. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JU63vyD4mRXpEGX5KKdyuB * feat(ui): show the true resolution outcome on resolved conflict cards A resolved conflict thread showed the wrong text under "Result" when it had been resolved by reject, and its resolution reply rendered an empty body. Both are now driven by the comment's conflictResolutionAction. - conflict-note-card: new resolvedResolution prop (accept/reject/merged), consulted only in the read-only ('none') state — Result shows resultText for accept, rejectedResultText for reject, and is hidden entirely for a 'merged' outcome (no stored field represents the merged verse). - CommentThread: derive resolvedResolution from the thread's comments (last-to-first first action: 'replaced'->reject, 'merged'->merged; none + Resolved -> accept) and pass it down. - CommentItem: render a localized outcome line in place of the resolution comment's empty body when conflictResolutionAction is present. - Add %conflict_note_outcome_replaced% / %conflict_note_outcome_merged% (en + es) and to CONFLICT_NOTE_STRING_KEYS (now 13). - Stories: the resolved-thread story and both resolve mocks stamp conflictResolutionAction: 'replaced' on a reject. - Tests: card (reject/accept/merged read-only Result), CommentItem (outcome line replaces body), CommentThread (replaced->reject, none+Resolved->accept). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JU63vyD4mRXpEGX5KKdyuB * chore(ui): regenerate platform-bible-utils and -react dist Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HUx81dkkHqJveHhqJAHg1F * refactor(comments): use IsFirstCommentInThread for ContentsHtml first-comment flag (PT-4104) Replaces a never-true `_comment.Id == _thread?.Id` comparison (comment id vs thread id) with the shared IsFirstCommentInThread helper. Behavior-neutral: the flag is only consulted when skipFirstChildNode is true, which this call site hardcodes false — the full comment-serialization suite is unchanged (183/183). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014CKj4FJgk9NrFtPioQbiGD * feat(comments): extend ConflictNoteCard types for merge resolution Add 'merge' to ConflictResolution and 'acceptRejectOrMerge' to ConflictResolutionOptions to match the backend's legacy-comment-manager.d.ts unions, and declare 8 new %conflict_note_*% string keys the redesigned card will use. Type-only change; no component edits (follow-up task wires the UI). * feat(comments): redesign ConflictNoteCard as direct-manipulation radios (PT-4030) Replace the Accept/Reject Select + Resolve button with a shadcn RadioGroup of "Keep the current text" (accept, preselected), "Use the other change" (reject), and "Combine both changes" (merge, only when availableActions is acceptRejectOrMerge). Each option shows its inline red/green diff (acceptedText/rejectedText/mergedText); the selected option is border-boxed. A Save-and-Resolve button (disabled on accept, with a "This can't be undone." tooltip) calls onResolve(effectiveResolution). Stale (availableActions='accept') keeps the accept option disabled with the sr-only + tooltip explanation and no merge/Save. Resolved read-only (availableActions='none') collapses to the chosen outcome's text plus a derived outcome line, now showing mergedText for a 'merged' outcome instead of hiding it. Non-verseText fallback is unchanged. Adds a hand-authored verseTextConflictMergeSample and rewrites the tests/stories for the new UI. Adjusts two comment-thread tests to the renamed button and radio interaction (comment-thread.component.tsx itself is untouched — task B6). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Session-URL: https://claude.ai/code/session_01AzT8SMKLTiNFJJ6Rjwg6LC * fix(comments): stale conflict card disables reject, not accept In the stale state (verse edited after the conflict was recorded), "Keep the current text" is the only still-valid resolution, so it must stay enabled and selected. Reject would clobber the post-merge edit, so it is now the option that is disabled and carries the stale explanation (sr-only span + Tooltip via aria-describedby). Previously this was inverted: accept was disabled and reject was left enabled. Also stopped forcing `isStale` into the RadioGroup's group-level `disabled`, since that disabled every item regardless of its own `disabled` prop, which would have kept accept non-interactive too. Updates the stale tests to assert the new accept-enabled/reject-disabled behavior, and corrects the stale-state doc comments in the component and its Storybook stories. * refactor(comments): make ConflictNoteCard options clickable cards Replace the bordered-radio option UI with clickable option cards where the whole card (label + inline diff) selects the option, while keeping radio-group accessibility. Each option is a <label> wrapping a visually-hidden RadioGroupItem (role=radio, aria-checked, arrow-key nav, aria-label naming); native label->control forwarding makes the whole card a single click target with no double-fire. Also: - Contain clicks at the card root so selecting an option or clicking Save no longer toggles the enclosing CommentThread. - Split the Save-and-resolve tooltip: enabled -> "This can't be undone." (%conflict_note_save_warning%, new); disabled -> the keep-current no-op reason (%conflict_note_save_disabled_tooltip%, repurposed). - Keep Save present-but-disabled in the stale state instead of unmounting it. - Rename the button copy to "Save and resolve" (sentence case). - Give the radio group an accessible name via %conflict_note_choose_aria_label%. - Build the option list with a spread instead of mutating a const via .push(). - Update the two stale ConflictResolutionOutcome/resolvedResolution doc-comments to say the card now shows mergedText for a 'merged' outcome. - Prune 7 dead string keys from CONFLICT_NOTE_STRING_KEYS (kept the two outcome_replaced/outcome_merged keys still used by CommentItem's banner). - Fix the leftover "Radix Select" comment in comment-thread.component.test.tsx. - Add tests: whole-card merge selection, group accessible name, Save tooltip split; update the stale test to assert Save present + disabled. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AzT8SMKLTiNFJJ6Rjwg6LC * feat(comments): route conflict thread ✓ to accept resolve, add reply gap Restores the resolve ✓ for unresolved CONFLICT threads (previously suppressed via !isConflictThread) and routes its onClick through handleResolveConflictClick('accept') instead of the generic handleAddCommentToThreadWithContents({status:'Resolved'}) path, which the backend now blocks for conflicts. Non-conflict threads keep the generic behavior. Also adds a conditional vertical spacer between the conflict card and its reply comments, rendered only when the conflict thread has at least one visible reply (Storybook feedback item 4), avoiding dead whitespace on conflict threads with no other comments. Co-authored-by: Claude <noreply@anthropic.com> * feat(comments): localize new conflict-card strings (en + es) Add the 9 conflict_note_* keys the redesigned ConflictNoteCard now uses (choose_prompt, option_keep_current/use_other/combine, save_and_resolve, save_disabled_tooltip, save_warning, outcome_used_other/combined) in both en and es, and update conflict_note_stale_notice to describe that rejecting is unavailable because the verse changed (was worded around "accept", the option it now gates is "Use the other change"). Remove 7 dead keys pruned from CONFLICT_NOTE_STRING_KEYS in prior work (choose_label, accept, reject, rejected_label, accepted_label, result_label, resolve) after confirming via repo-wide grep they have no remaining references outside this file. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AzT8SMKLTiNFJJ6Rjwg6LC * chore(ui): regenerate dist for conflict-card merge UX Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * refactor(comments): dedupe resolved-outcome text to the reply banner only A resolved conflict thread stated the outcome twice and inconsistently: the ConflictNoteCard's resolved Result region (neutral) and CommentItem's resolution-reply banner (old PT9 "Replaced/Merged the changes that Paratext ACCEPTED with the changes that Paratext REJECTED"). Consolidate onto a single, neutral statement: the card now shows only the chosen Result text, and the reply banner states the outcome using the card's existing neutral keys (%conflict_note_outcome_used_other%/%conflict_note_outcome_combined%). Deletes the now-unused %conflict_note_outcome_replaced%/%conflict_note_outcome_merged% keys (en + es) and updates stories/tests to match. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AzT8SMKLTiNFJJ6Rjwg6LC * chore(ui): regenerate platform-bible-react dist for outcome-text dedupe Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AzT8SMKLTiNFJJ6Rjwg6LC * style(comments): visible inline radio + tighter verse diff on conflict cards Make the conflict option's radio visible again (dropping the sr-only treatment) and lay it out inline with the option title on one row, with the verse diff below — removing the dead top-whitespace the hidden radio left above the title. Zero the top/bottom margin on DIFF_HTML_CLASSES's prose-driven block elements (blockquote/p) so the diff sits flush inside the now-tighter cards; verified this is safe for the non-verseText fallback render too. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AzT8SMKLTiNFJJ6Rjwg6LC * chore(ui): regenerate platform-bible-react dist for conflict-card layout fix Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AzT8SMKLTiNFJJ6Rjwg6LC * docs(comments): correct 'merged' doc-rot comments This PR makes PT10 produce conflictResolutionAction: 'merged' (the new "Combine both changes" resolution), so the claim that 'merged' only comes from PT9-synced data was no longer accurate. Update the ConflictResolutionOutcome doc and the CommentItem banner comment to describe the current behavior instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AzT8SMKLTiNFJJ6Rjwg6LC * fix(comments): correct Save tooltip, ✓ double-click, and a11y label wiring - Save tooltip: showing "This can't be undone." over a Save button disabled solely by isResolving was misleading (the button can't be clicked, so the irreversibility warning doesn't apply). Now the warning only shows when Save is actually enabled; the no-change disabled tooltip still shows for keep-current/stale; a resolving-only disable shows no tooltip at all. - Conflict resolve ✓: disable it while isResolvingConflict so a fast double-click can't fire two resolveConflict('accept') calls (the backend lock keeps data safe, but this avoided a duplicate error toast). - Dropped the avoidable jsx-a11y/label-has-associated-control eslint-disable on the resolution option <label> by adding htmlFor={optionId}, since the wrapped RadioGroupItem already has a matching id. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AzT8SMKLTiNFJJ6Rjwg6LC * fix(comments): sync aggregate stories' mock strings with the current card keys comment-thread.stories.tsx and comment-list.stories.tsx still carried the 7 conflict_note_* keys removed from ConflictNoteCard's key list and were missing the 7 that replaced them, so the card rendered EN-fallback text in those two stories instead of the mocked localization. Mirror conflict-note-card.stories.tsx's mock values. Also fix a story console.log's title-case ("Save and Resolve clicked") to sentence case, matching the project's copy convention. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AzT8SMKLTiNFJJ6Rjwg6LC * test(comments): cover isResolving tooltip, ✓ double-click guard, and resolved-merged thread - ConflictNoteCard: Save tooltip is suppressed (not the irreversibility warning) when Save is disabled solely by isResolving. - CommentThread: a fast double-click on the conflict ✓ fires resolveConflict only once while the first call is in flight. - CommentThread: an end-to-end resolved-by-merge thread (a resolution reply with conflictResolutionAction: 'merged') renders the card read-only with the merged text AND the resolution reply's "Combined both changes." banner, asserting the thread-level wiring between the two (each is already unit-tested in isolation). Verified each new test fails without its corresponding fix (RED before GREEN). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AzT8SMKLTiNFJJ6Rjwg6LC * chore(ui): regenerate platform-bible-react dist for final-review fix sweep Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AzT8SMKLTiNFJJ6Rjwg6LC * feat(comments): status-aware collapsed conflict summary with red/green diffs Replace the raw PT9-generated note body in collapsed threads with a custom summary: - Unresolved: 'Conflicting edits. Choose which change to keep.' + the red/green diff - Resolved: 'Conflicting edits were resolved. [outcome]' with no verse text Extracts DiffHtml into a shared module for reuse. Fixes misleading '(in red)' text with no actual coloring, and stale outcome text post-resolution. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> * feat(comments): wire collapsed conflict summary into the thread + localize Complete the status-aware collapsed conflict summary (component landed in the prior commit): render ConflictThreadSummary for a collapsed verseText conflict thread instead of the raw PT9 note body, add the en/es localized strings, Storybook coverage (collapsed unresolved + resolved states), and unit + thread-level tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AzT8SMKLTiNFJJ6Rjwg6LC * chore(ui): regenerate platform-bible-react dist for collapsed conflict summary Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AzT8SMKLTiNFJJ6Rjwg6LC * fix: address PT-4029 conflict-resolution review findings - Refuse resolveConflict "merge" on overlapping edits (GetMergedUsfm null) — it was splicing null into the chapter USFM and silently erasing the verse - Require chapter CanEdit for non-admin conflict resolvers (match PT9) so a non-admin assignee can't write to a chapter they aren't permitted to edit - Hoist ConflictResolutionOptions into platform-bible-utils as the single source of truth; re-export it from legacy-comment-manager - Rename IsThreadAssignedToCurrentUser -> IsThreadAssignedToUser - Align "thread does not exist" exception-message punctuation - Add C# tests for merge-on-overlapping and non-admin-without-chapter-edit denial Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Session-URL: https://claude.ai/code/session_015hfnmegSuB5vVmfNCvAtqk * refactor(comments): source ConflictResolutionOptions from platform-bible-utils Drop the hand-duplicated ConflictResolutionOptions union (and its "must stay textually identical to legacy-comment-manager.d.ts" comment) from conflict-note-card.types.ts now that platform-bible-utils exports it as the single source of truth (brought in by the merge of pt-4029). The React card and the extension type declaration both import the one definition. Note: lib/platform-bible-react/dist needs a `npm run build` regen in a clean checkout to reflect this; the exported type value is unchanged. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Session-URL: https://claude.ai/code/session_015hfnmegSuB5vVmfNCvAtqk * fix(comments): re-export ConflictResolutionOptions and regen platform-bible-react dist The prior dedup commit removed the local ConflictResolutionOptions declaration from conflict-note-card.types.ts but left the name only imported, breaking the files that import it from that module. Re-export it (the single definition still lives in platform-bible-utils) so consumers keep their import path, and regenerate platform-bible-react/dist to match — types now import the union from platform-bible-utils; the runtime bundle is unchanged (only source maps shifted). Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Session-URL: https://claude.ai/code/session_015hfnmegSuB5vVmfNCvAtqk * fix(comments): guard empty conflict thread and export ConflictResolutionOutcome - comment-thread: an all-deleted Conflict thread has no first active comment, but `isVerseTextConflictThread` dereferenced `firstComment` before the empty-thread guard, throwing "Cannot read properties of undefined". Guard the access with optional chaining and add a regression test. - Export `ConflictResolutionOutcome` from the platform-bible-react barrel — it is the declared type of the public `ConflictNoteCardProps.resolvedResolution`, so extension developers must be able to name it. - Regenerated platform-bible-react/dist to match. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Session-URL: https://claude.ai/code/session_015hfnmegSuB5vVmfNCvAtqk * refactor(comments): share VERSE_TEXT_CONFLICT and align stale-notice fallback - Extract the 'verseText' magic string into a shared VERSE_TEXT_CONFLICT constant in conflict-note-card.types.ts, reused by ConflictNoteCard and CommentThread so the literal can't drift between them. - Align the ConflictNoteCard stale-notice English fallback with the shipped localized value (and update the fallback-path test to match). - Regenerated platform-bible-react/dist. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Session-URL: https://claude.ai/code/session_015hfnmegSuB5vVmfNCvAtqk * fix(comments): address second-pass review on resolveConflict backend Resolves the 14 findings from the follow-up review on #2495 (all against the current tip; several verified against the PT9 ParatextData source). Correctness / robustness: - Resolve-via-status guard now fires only for verseText conflicts. Gating on Type == Conflict blocked every other conflict type (invalidVerses, readError, verseBridge, ...) from being marked resolved through any API path. + test. - Contain a MergedText serialization throw in PlatformCommentConverter.Write so one malformed conflict can no longer drop the entire getCommentThreads response. - Guard the empty-Comments access (PT9's CommentThread.FirstComment is private). - MergedText base now diffs against the same comment GetMergedUsfm uses (Comments[0]), not the root, avoiding a garbled preview when they differ. - Log unexpected errors in GetConflictResolutionOptions / IsConflictVerseStale instead of silently degrading; expected domain outcomes stay quiet. Concurrency: - Serialize all comment mutations (ResolveConflict, AddCommentToThread, CreateComment, UpdateComment, DeleteComment) under a single _commentMutationLock so their read-modify-write of the shared CommentManager is atomic. The previous lock was resolve-only. PT9 is single-threaded, so this concurrency is new in PT10. + concurrency test. Cleanup: - VerifyUserCanResolveConflict returns the verified thread, removing two redundant re-finds and two dead null-checks; extract writesVerse. - Pin the intentional divergences from PT9 CommentHtmlBuilder.GetResolutionOptions. - Characterization test pinning PT9's reject-on-whole-verse-deletion write path (faithful delegation to ReplaceAcceptedText; a guard would diverge from PT9). - Convert PR-introduced em dashes to hyphens. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VuDwiegLDT9u7dD7k3YrB * fix(comments): address PR #2497 review - extract ConflictThread + card fixes Second-half of the conflict-resolution review pass. Headline is finding #15: CommentThread is now a conflict-agnostic shell with three optional slots (rootContent / resolveAction / spaceRootContentFromReplies); a new ConflictThread container fills them and owns all conflict state via a new useConflictResolution hook, and the two scattered conflict callbacks collapse into one conflictResolution?: { resolve, getOptions } slot. That refactor lands the interlocking card fixes together: - Loading sentinel ('loading' + skeleton) + fetch-on-select + try/catch - kills the reject/merge result flash and the eager per-thread option fetch, with the chosen outcome threaded optimistically so the correct side shows immediately. - Resolve check gated on real resolvability (no error toast to non-assignees, no double-resolve). Collapsed conflict threads no longer show a quick-check - you open a conflict to resolve it. - Status-aware resolvedResolution; a shared isVerseTextConflictNote gated on conflictType alone so empty-result verseText conflicts stay resolvable; non-verseText conflicts keep the CommentItem chrome; uncontrolled card; empty-state Result region. Contract + cleanups (already reviewed separately): - Narrow conflictResolutionAction to 'replaced' | 'merged'; correct the false "PT10 never produces 'merged'" docs; restore converter value-binding assertions; add a merge-stamp test. - Compose the dead COMMENT_BODY_PROSE_CLASSES instead of triplicating it; memoize the conflict-summary sanitize; freeze CONFLICT_NOTE_STRING_KEYS. Regenerated platform-bible-react and platform-bible-utils dist. Design captured in .context/plans/pt-4030-conflict-thread-extraction.md. Deferred (per PR dialogue): the I-3 tooltip/keyboard a11y (UX ticket), and #15's optional lighter touch; the collapsed-check removal was an explicit decision. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VuDwiegLDT9u7dD7k3YrB * style(comments): remove transient ticket references from comments Drop self-referential PR/ticket tags (PT-4029 review, PT-4107) from code and test comments, keeping the explanatory prose. Ticket context belongs in the PR/commit, not the source. Comment-only; no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017vhggZ6RR1ctmvMK7akLdW * style(comments): drop review-finding label (B2) from concurrency test comment Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017vhggZ6RR1ctmvMK7akLdW * fix(tests): make DummyScrText.ProjectFiles enumerate in-memory files CommentManager.Load() rebuilds its comment list solely from FileManager.ProjectFiles("Notes_*.xml"). The dummy file manager's ProjectFiles was a stub returning nothing, so any reload (triggered in the full test assembly by a WriteLockManager notification, e.g. the post-seed verse edit) rebuilt an empty comment list and dropped seeded comments. This made the verseText-conflict tests flake ~2.5-5% of full-assembly runs with "Thread with id 5f5ea40f does not exist" (masked to "none" in the GetConflictResolutionOptions path); they passed in isolation because the in-memory comment list still held the comment. Implement ProjectFiles to return the in-memory files whose name matches the glob within relDirPath, and add GetConflictResolutionOptions_SurvivesComment- ManagerReload, which forces a reload and asserts the seeded conflict thread survives (fails without the fix, returning "none"). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LwRALJ5UQZUvoNGew1He6F * fix(comments): address round-2 review on conflict-card integration (#2497) - Locate the conflict root by conflictType, not comments[0], so a fragment-merged verseText conflict still renders the resolution card - Derive the resolved outcome from the most-recent resolution comment (reject -> reopen -> accept no longer reports the stale reject) - Keep a resolution comment's typed body instead of replacing it with the outcome banner; only an empty resolution body shows the banner - Add a catch + reentrancy guard to the resolve() callback - Share actionToOutcome; move conflictResolution off the generic shell props into ConflictThreadProps; drop the dead DiffHtml className prop; compute isStaleReject once; hoist the no-result node; de-duplicate the resolved status line; reuse pre-filtered activeComments - Tests: positive CommentItem-chrome assertion, falsifiable localization sentinels, merge stamp in the story resolve mocks, distinct C# fixture name Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Adds the write half of merge-conflict resolution (PT-4029, epic PT-4024): a
resolveConflict(threadId, 'accept' | 'reject')mutation on the comments data provider that applies averseTextconflict's resolution and marks the note resolved, so the conflict card (PT-4030) has a backend to call.Stacked on #2482 (
pt-4028-conflict-note-fields) — reuses itsCreateVerseTextConflictCommenttest fixture; the base is intentionally that branch, mirroring how #2484 stacks on #2482.What it does
ResolveConflict(threadId, resolution)onParatextProjectDataProvider, registered as theresolveConflictcomment wire method alongside the existing comment mutations.CommentEditHelper.SaveEdits(owner: null, …)orchestration: accept resolves the note and leaves the auto-merged (winning) verse text untouched; reject writes the losing side's USFM into the verse (temporary chapter-edit grant → splice → restore), then resolves.VerifyUserCanResolveThreadcheck).verseTextconflict, already-resolved thread (prevents a reject-after-accept from rewriting a resolved verse), and a canceledSaveEdits.PutTextinsideSaveEditsbypasses theSet*methods that normally notify open editors.resolveConflict(threadId: string, resolution: 'accept' | 'reject'): Promise<void>declared onILegacyCommentProjectDataProvider.Notes for reviewers
canUserResolveThreadis a broader gate than this method's admin-or-assignee check — the conflict UI should not use it to enable Accept/Reject.SaveEdits(ReplaceAcceptedTextno-ops with a trace if the verse marker is missing) — inherited PT9 behavior, documented in the method remarks.Tests
11 new NUnit tests in
ParatextProjectDataProviderCommentTests: accept (strict full-verse no-write assertion) / reject (loser text written) / guards (non-conflict, invalid resolution, reject-after-accept, creator-resolve safety pin) / permissions (non-admin denied, non-admin assignee reject succeeds and writes, most-recent reassignment wins) / data-update events (accept → comment update only; reject → comment + scripture updates). Full comment-test class 88/88 green;npm run typecheckclean.🤖 Generated with Claude Code
https://claude.ai/code/session_01JU63vyD4mRXpEGX5KKdyuB
This change is
Automated review summary
Code Review Summary
Branch: pt-4029-resolve-conflict
Base: origin/main
Date: 2026-07-08
Review model: Claude Opus 4.8
Files changed: 9
Overview
PT-4029 adds a
resolveConflict(accept / reject / merge) write backend forverseTextmergeconflicts in the legacy comment/conflict system. Resolving applies the chosen resolution to the
underlying ParatextData via PT9's
CommentEditHelper.SaveEdits; it is gated by permissions(admin / assignee / team) and, for reject/merge, a staleness guard, and it is serialized under
_resolveConflictLockso the already-resolved check is atomic with the write (closing acheck-then-act race). It also adds a PT9 merge-diff preview (
MergedText→mergedTextJSON), agetConflictResolutionOptionscapability that reports which actions are available, and theresolveConflict/getConflictResolutionOptionsmethods on the legacy comment PDP interface plusthe supporting
platform-bible-utilstypes.The concurrency design, JSON serialization, and cross-language contract are sound and exceptionally
well-tested (an 803-line test addition covering all three resolutions, the 8-task concurrency race,
the full permission matrix, and the staleness paths). Review surfaced one critical data-loss bug
in the merge path and one important permission gap, both now fixed and verified with C# tests;
plus a maintainability fix (a hand-duplicated type) and two nits.
API Changes
lib/platform-bible-utils: AddedmergedText?: stringtoLegacyComment(read-only displayfield; regenerated in
dist/index.d.ts).lib/platform-bible-utils: (added during review) AddedConflictResolutionOptions = 'none' | 'accept' | 'acceptOrReject' | 'acceptRejectOrMerge'as the single source of truth forthe conflict-resolution capability union (regenerated in
dist/index.d.ts).extensions/src/legacy-comment-manager: AddedresolveConflict(threadId, resolution)andgetConflictResolutionOptions(threadId)toILegacyCommentProjectDataProvider; addedConflictResolutionOptions(now re-exported fromplatform-bible-utilsrather than declaredlocally); added
mergedTextto the omit lists ofNewLegacyComment/LegacyCommentReply;documented a new
@throwsonaddCommentToThread(throws when setting'Resolved'on a mergeconflict — use
resolveConflict).All API changes are additive; no exports removed or narrowed.
Findings
Critical — Must address before merge
resolveConflict(id, "merge")on an overlapping-edit conflict erased the verse. The writepath gated
mergeonly on staleness, never on merge availability. For overlapping editsCommentEditHelper.GetMergedUsfmreturnsnull, and PT9'sMergeAcceptedTextsplices that nullinto the chapter USFM (C# concatenates
nullas""), deleting the entire verse (\vmarker andall) and writing it back via
PutText;SaveEditsstill returnedtrue, so the thread was markedResolved and success events fired — silent scripture loss. (fixed during review:
ResolveConflictnow throws when
resolution == "merge" && GetMergedUsfm(thread) == null, mirroring the invariantGetConflictResolutionOptionsalready computes. New testResolveConflict_MergeOnOverlappingConflict_ThrowsAndLeavesVerseIntact;revert-verified — without the gate the test fails with "no exception thrown", i.e. the verse is
erased.) (
c-sharp/Projects/ParatextProjectDataProvider.cs)(Author: skip — the throw only affects the conflict-resolutionaddCommentToThread(status: 'Resolved')on a verseText conflict thread now throws (itpreviously resolved it); the compensating UI that routes conflicts to
resolveConflictlives onthe separate
pt-4030branch.path, which
pt-4030moves entirely toresolveConflict; normal-thread resolves are unaffected.Accepted as a landing-order item: do not release PT-4029: feat(comments): resolveConflict (accept/reject) write backend for verseText merge conflicts #2495 to end-users ahead of
pt-4030.)Important — Should address before merge
CanEdit(book, chapter)requirement for non-admin resolvers.PT9's
GetResolutionOptionsallows a non-admin only when assignee andPermissions.CanEdit(book, chapter); the PR checked admin-or-assignee but not chapter-edit rights,and
SaveEdits'sEnsureCanEditChaptertemporarily grants the edit — so a non-admin assignee (orany team member, for team-assigned conflicts) could write a verse in a chapter they cannot edit.
(fixed during review:
VerifyUserCanResolveConflictnow also requiresscrText.Permissions.CanEdit(vref.BookNum, vref.ChapterNum)for non-admins. The existing "allowed"non-admin fixture was given chapter-edit rights (so those tests now mean "assignee WITH edit
rights"), and two new tests cover the denied-without-edit case; revert-verified.)
(
c-sharp/Projects/ParatextProjectDataProvider.cs)ConflictResolutionOptionswas hand-duplicated with a dangling reference. The sync commentpointed at
platform-bible-react'sconflict-note-card.types.ts, which does not exist in thisbranch or on
main(only onpt-4030), andpt-4030carries a third copy — the union was headedfor triplication with reciprocal "keep textually identical" comments. (fixed during review: hoisted
the union into
lib/platform-bible-utils/src/comments.types.ts(both consumers already import fromthere), re-exported from
legacy-comment-manager.d.ts, dist regenerated. Follow-up onpt-4030:drop its local copy and import from
platform-bible-utilstoo.)Minor — Consider
(Author's deliberate design:IsConflictVerseStaleregularizes AND.Trim()s both sides, more lenient than PT9 (whichregularizes only the recorded side and never trims) — a whitespace-only post-merge edit is not
classified stale and could be clobbered by a later reject/merge.
there is a
MatTwoWinnerWhitespaceUsfmfixture asserting this is intended; documented divergence.)MergedTextgetter (and its sibling conflict getters) are not exception-guarded duringserialization — a malformed conflict comment reaching
XmlDocument.LoadXmlwould propagate out ofPlatformCommentConverter.Writeand fail the entiregetCommentThreadsserialization, not justthat field. Held off during review:
RejectedText/AcceptedText/RejectedResultTextshare thesame unguarded
LoadXml, so a guard onMergedTextalone is inconsistent, and a silent null-catchis its own smell. Recommended follow-up: per-comment resilience in
PlatformCommentConverter.Write(skip + log a comment that fails to serialize) so one malformed comment can't drop the whole thread
list. (
c-sharp/JsonUtils/PlatformCommentWrapper.cs)
(Left — in-memory lookup, low value;VerifyUserCanResolveConflict/ResolveConflict/GetConflictResolutionOptionseach callFindThread(threadId)redundantly (up to 3× per call).having the verify helper return the thread would churn the diff for negligible benefit.)
IsThreadAssignedToCurrentUserwas misnamed (it takes auserNameparameter and has nonotion of "current"). (fixed during review: renamed to
IsThreadAssignedToUser.)InvalidDataException("...does not exist")throws lacked the trailing period used by the surrounding exception messages. (fixedduring review: added the period to the three new throws. The exception type —
InvalidDataException,documented in the TSDoc and used consistently across the new code — was intentionally left as-is.)
The
(Left —resolveConflictaction union ('accept'|'reject'|'merge') and thegetConflictResolutionOptionscapability union read similarly but mean different things.correctly distinct concepts; the TSDoc already cross-links them.)
Template Propagation
Shared Regions Modified
None. No
#region shared withmarkers in any of the 9 changed files.Extension Config Changes
None. The only
extensions/file is a type-declaration file; nopackage.json/tsconfig/webpack/.eslintrc*/CI changes.Positive Observations
_resolveConflictLockcorrectly makes the already-resolved guard atomic with theSaveEditswrite, closing the check-then-act race — and it's genuinely tested with 8 racing tasks asserting
exactly-once resolution and final verse content.
SaveEdits,MergedText'sRenderConflictSideHtml) ratherthan reimplementation;
ResolveConflictandGetConflictResolutionOptionsshare oneVerifyUserCanResolveConflict(enforcement vs. capability).IsResolved/mergedTextcross-process contract (TS ↔ JSON ↔ C#) matches exactly;distregeneration mirrors
src.the staleness paths (including the
Done-status and unreadable-verse edges), and both mergeable(independent) and non-mergeable (overlapping) conflict fixtures.
Interview Notes
resolveConflictaccept/reject/merge write backendfor verseText merge conflicts, with server-side permission and staleness enforcement.
pt-4030moves conflictresolution to
resolveConflict; recorded as an accepted landing-order item.(a non-admin assignee without chapter-edit rights could resolve via
EnsureCanEditChapter's temporarygrant); the author confirmed they want to match PT9 strictly, so that test was re-scoped to
"assignee WITH edit rights" and a denial test added.
platform-bible-utilsas the single source oftruth; a
pt-4030follow-up is needed to drop that branch's local copy.In-Review Quality Check
Changes were made during review (C1, I1, I2, M4, M5), touching 6 files (2 C#, 4 TS):
c-sharp-testssuite — 1366 passed, 6 skipped, 0 failed; theconflict/resolution subset (112 tests) green. C1 and I1 were revert-tested (each fix's tests fail
when the fix is disabled).
platform-bible-utilstypecheck clean;dist/index.d.tsregenerated via the library build(
dts-bundle-generator); prettier clean.tsccould not run cleanly inside the isolated reviewworktree — its
node_modulessymlink resolvesplatform-bible-utils/legacy-comment-managertothe other (
pt-4030) checkout, which lacks the newConflictResolutionOptionsexport, producing aspurious
TS2694/TS6200. This is a cross-checkout artifact, not a code defect. Runnpm run typecheckin a normal checkout (or rely on CI) as the final gate for the I2 import wiring.Suggested Review Focus
merge(vs. silently degrading to accept/reject) is the desiredbehavior when the two sides overlap.
CanEdit(book, chapter)for non-admin resolvers(this tightens behavior vs. what the PR originally shipped and its original test asserted).
PlatformCommentConverter.Writeso a single malformed conflict can't fail the whole
getCommentThreadsresponse.pt-4030(see C2), andpt-4030needsa small follow-up to import
ConflictResolutionOptionsfromplatform-bible-utils(see I2).npm run typecheckin a standard checkout (see the typecheck caveat).AI-assisted review — session