Skip to content

PT-4029: feat(comments): resolveConflict (accept/reject) write backend for verseText merge conflicts#2495

Merged
rolfheij-sil merged 25 commits into
mainfrom
pt-4029-resolve-conflict
Jul 9, 2026
Merged

PT-4029: feat(comments): resolveConflict (accept/reject) write backend for verseText merge conflicts#2495
rolfheij-sil merged 25 commits into
mainfrom
pt-4029-resolve-conflict

Conversation

@rolfheij-sil

@rolfheij-sil rolfheij-sil commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

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 a verseText conflict'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 its CreateVerseTextConflictComment test fixture; the base is intentionally that branch, mirroring how #2484 stacks on #2482.

What it does

  • ResolveConflict(threadId, resolution) on ParatextProjectDataProvider, registered as the resolveConflict comment wire method alongside the existing comment mutations.
  • Reuses PT9's 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.
  • Permission gate: only a project administrator, or the user the conflict is assigned to, may resolve (on top of the base VerifyUserCanResolveThread check).
  • Guards that all throw before any event fires: invalid resolution string, missing thread, non-verseText conflict, already-resolved thread (prevents a reject-after-accept from rewriting a resolved verse), and a canceled SaveEdits.
  • Refresh: fires a comment data-update for both paths; reject also fires a Scripture-text data-update, because the raw PutText inside SaveEdits bypasses the Set* methods that normally notify open editors.
  • TS: resolveConflict(threadId: string, resolution: 'accept' | 'reject'): Promise<void> declared on ILegacyCommentProjectDataProvider.

Notes for reviewers

  • The already-resolved guard goes slightly beyond the design doc (which was silent on re-resolution); it blocks a state transition PT9's UI structurally never allows. Flagging in case that behavior should differ.
  • PT-4030 consumer note: canUserResolveThread is a broader gate than this method's admin-or-assignee check — the conflict UI should not use it to enable Accept/Reject.
  • The reject write is best-effort inside PT9's SaveEdits (ReplaceAcceptedText no-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 typecheck clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JU63vyD4mRXpEGX5KKdyuB


This change is Reviewable


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 for verseText merge
conflicts 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
_resolveConflictLock so the already-resolved check is atomic with the write (closing a
check-then-act race). It also adds a PT9 merge-diff preview (MergedTextmergedText JSON), a
getConflictResolutionOptions capability that reports which actions are available, and the
resolveConflict / getConflictResolutionOptions methods on the legacy comment PDP interface plus
the supporting platform-bible-utils types.

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: Added mergedText?: string to LegacyComment (read-only display
    field; regenerated in dist/index.d.ts).
  • lib/platform-bible-utils: (added during review) Added ConflictResolutionOptions = 'none' | 'accept' | 'acceptOrReject' | 'acceptRejectOrMerge' as the single source of truth for
    the conflict-resolution capability union (regenerated in dist/index.d.ts).
  • extensions/src/legacy-comment-manager: Added resolveConflict(threadId, resolution) and
    getConflictResolutionOptions(threadId) to ILegacyCommentProjectDataProvider; added
    ConflictResolutionOptions (now re-exported from platform-bible-utils rather than declared
    locally); added mergedText to the omit lists of NewLegacyComment / LegacyCommentReply;
    documented a new @throws on addCommentToThread (throws when setting 'Resolved' on a merge
    conflict — 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 write
    path gated merge only on staleness, never on merge availability. For overlapping edits
    CommentEditHelper.GetMergedUsfm returns null, and PT9's MergeAcceptedText splices that null
    into the chapter USFM (C# concatenates null as ""), deleting the entire verse (\v marker and
    all) and writing it back via PutText; SaveEdits still returned true, so the thread was marked
    Resolved and success events fired — silent scripture loss. (fixed during review: ResolveConflict
    now throws when resolution == "merge" && GetMergedUsfm(thread) == null, mirroring the invariant
    GetConflictResolutionOptions already computes. New test ResolveConflict_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)
  • addCommentToThread(status: 'Resolved') on a verseText conflict thread now throws (it
    previously resolved it); the compensating UI that routes conflicts to resolveConflict lives on
    the separate pt-4030 branch.
    (Author: skip — the throw only affects the conflict-resolution
    path, which pt-4030 moves entirely to resolveConflict; 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

  • Permission gate dropped PT9's CanEdit(book, chapter) requirement for non-admin resolvers.
    PT9's GetResolutionOptions allows a non-admin only when assignee and
    Permissions.CanEdit(book, chapter); the PR checked admin-or-assignee but not chapter-edit rights,
    and SaveEdits's EnsureCanEditChapter temporarily grants the edit — so a non-admin assignee (or
    any team member, for team-assigned conflicts) could write a verse in a chapter they cannot edit.
    (fixed during review: VerifyUserCanResolveConflict now also requires
    scrText.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)
  • ConflictResolutionOptions was hand-duplicated with a dangling reference. The sync comment
    pointed at platform-bible-react's conflict-note-card.types.ts, which does not exist in this
    branch or on main (only on pt-4030), and pt-4030 carries a third copy — the union was headed
    for 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 from
    there), re-exported from legacy-comment-manager.d.ts, dist regenerated. Follow-up on pt-4030:
    drop its local copy and import from platform-bible-utils too.)

Minor — Consider

  • IsConflictVerseStale regularizes AND .Trim()s both sides, more lenient than PT9 (which
    regularizes 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.
    (Author's deliberate design:
    there is a MatTwoWinnerWhitespaceUsfm fixture asserting this is intended; documented divergence.)
  • MergedText getter (and its sibling conflict getters) are not exception-guarded during
    serialization
    — a malformed conflict comment reaching XmlDocument.LoadXml would propagate out of
    PlatformCommentConverter.Write and fail the entire getCommentThreads serialization, not just
    that field. Held off during review: RejectedText/AcceptedText/RejectedResultText share the
    same unguarded LoadXml, so a guard on MergedText alone is inconsistent, and a silent null-catch
    is 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)
  • VerifyUserCanResolveConflict / ResolveConflict / GetConflictResolutionOptions each call
    FindThread(threadId) redundantly (up to 3× per call).
    (Left — in-memory lookup, low value;
    having the verify helper return the thread would churn the diff for negligible benefit.)
  • IsThreadAssignedToCurrentUser was misnamed (it takes a userName parameter and has no
    notion of "current"). (fixed during review: renamed to IsThreadAssignedToUser.)
  • Exception-message punctuation inconsistency — the new InvalidDataException("...does not exist") throws lacked the trailing period used by the surrounding exception messages. (fixed
    during review: added the period to the three new throws. The exception typeInvalidDataException,
    documented in the TSDoc and used consistently across the new code — was intentionally left as-is.)
  • The resolveConflict action union ('accept'|'reject'|'merge') and the
    getConflictResolutionOptions capability union read similarly but mean different things.
    (Left —
    correctly distinct concepts; the TSDoc already cross-links them.)

Template Propagation

Shared Regions Modified

None. No #region shared with markers in any of the 9 changed files.

Extension Config Changes

None. The only extensions/ file is a type-declaration file; no package.json/tsconfig/webpack/
.eslintrc*/CI changes.

Positive Observations

  • The _resolveConflictLock correctly makes the already-resolved guard atomic with the SaveEdits
    write, closing the check-then-act race — and it's genuinely tested with 8 racing tasks asserting
    exactly-once resolution and final verse content.
  • Strong reuse of PT9 infrastructure (SaveEdits, MergedText's RenderConflictSideHtml) rather
    than reimplementation; ResolveConflict and GetConflictResolutionOptions share one
    VerifyUserCanResolveConflict (enforcement vs. capability).
  • The IsResolved/mergedText cross-process contract (TS ↔ JSON ↔ C#) matches exactly; dist
    regeneration mirrors src.
  • Exemplary test coverage: all three resolutions, the concurrency race, the full permission matrix,
    the staleness paths (including the Done-status and unreadable-verse edges), and both mergeable
    (independent) and non-mergeable (overlapping) conflict fixtures.

Interview Notes

  • Purpose (author-confirmed via PR title): a resolveConflict accept/reject/merge write backend
    for verseText merge conflicts, with server-side permission and staleness enforcement.
  • C1 (merge data loss): author asked for the fix; applied + tested (incl. revert test).
  • C2 (addCommentToThread throw): author chose to skip, on the basis that pt-4030 moves conflict
    resolution to resolveConflict; recorded as an accepted landing-order item.
  • I1 (chapter-edit gate): the author's existing test deliberately asserted the relaxed behavior
    (a non-admin assignee without chapter-edit rights could resolve via EnsureCanEditChapter's temporary
    grant); 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.
  • I2 (union hoist): author confirmed hoisting to platform-bible-utils as the single source of
    truth; a pt-4030 follow-up is needed to drop that branch's local copy.
  • No areas were deferred to AI or left unexplained; the author engaged directly on every Critical/Important.

In-Review Quality Check

Changes were made during review (C1, I1, I2, M4, M5), touching 6 files (2 C#, 4 TS):

  • C# tests: full c-sharp-tests suite — 1366 passed, 6 skipped, 0 failed; the
    conflict/resolution subset (112 tests) green. C1 and I1 were revert-tested (each fix's tests fail
    when the fix is disabled).
  • C# format: csharpier-formatted.
  • TS: platform-bible-utils typecheck clean; dist/index.d.ts regenerated via the library build
    (dts-bundle-generator); prettier clean.
  • Typecheck caveat: the extension-level tsc could not run cleanly inside the isolated review
    worktree — its node_modules symlink resolves platform-bible-utils / legacy-comment-manager to
    the other (pt-4030) checkout, which lacks the new ConflictResolutionOptions export, producing a
    spurious TS2694 / TS6200. This is a cross-checkout artifact, not a code defect. Run
    npm run typecheck in a normal checkout (or rely on CI)
    as the final gate for the I2 import wiring.

Suggested Review Focus

  • The C1 fix: confirm refusing merge (vs. silently degrading to accept/reject) is the desired
    behavior when the two sides overlap.
  • I1: confirm the intent to require standing CanEdit(book, chapter) for non-admin resolvers
    (this tightens behavior vs. what the PR originally shipped and its original test asserted).
  • M2 follow-up: decide whether to add per-comment resilience to PlatformCommentConverter.Write
    so a single malformed conflict can't fail the whole getCommentThreads response.
  • Landing order: PT-4029: feat(comments): resolveConflict (accept/reject) write backend for verseText merge conflicts #2495 should not reach end-users ahead of pt-4030 (see C2), and pt-4030 needs
    a small follow-up to import ConflictResolutionOptions from platform-bible-utils (see I2).
  • Full npm run typecheck in a standard checkout (see the typecheck caveat).

AI-assisted review — session

@rolfheij-sil
rolfheij-sil marked this pull request as ready for review July 2, 2026 10:13
@rolfheij-sil rolfheij-sil changed the title feat(comments): resolveConflict (accept/reject) write backend for verseText merge conflicts (PT-4029) PT-4029: feat(comments): resolveConflict (accept/reject) write backend for verseText merge conflicts Jul 2, 2026
@rolfheij-sil
rolfheij-sil force-pushed the pt-4029-resolve-conflict branch 3 times, most recently from 86380a8 to 0773ead Compare July 3, 2026 11:53
Base automatically changed from pt-4028-conflict-note-fields to main July 6, 2026 09:43
rolfheij-sil and others added 5 commits July 6, 2026 11:51
…ata provider

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
@rolfheij-sil
rolfheij-sil force-pushed the pt-4029-resolve-conflict branch from 0773ead to dc9e8f6 Compare July 6, 2026 09:54
rolfheij-sil and others added 7 commits July 6, 2026 14:05
…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
@rolfheij-sil
rolfheij-sil enabled auto-merge (squash) July 7, 2026 14:43
rolfheij-sil and others added 4 commits July 7, 2026 19:58
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
rolfheij-sil and others added 5 commits July 7, 2026 20:21
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 lyonsil left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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).

Comment thread c-sharp/Projects/ParatextProjectDataProvider.cs Outdated
Comment thread c-sharp/Projects/ParatextProjectDataProvider.cs Outdated
Comment thread c-sharp/Projects/ParatextProjectDataProvider.cs Outdated
Comment thread c-sharp/Projects/ParatextProjectDataProvider.cs Outdated
Comment thread c-sharp/Projects/ParatextProjectDataProvider.cs Outdated
Comment thread c-sharp/Projects/ParatextProjectDataProvider.cs
Comment thread c-sharp/JsonUtils/PlatformCommentWrapper.cs
Comment thread c-sharp/JsonUtils/PlatformCommentWrapper.cs
Comment thread c-sharp/JsonUtils/PlatformCommentWrapper.cs
Comment thread lib/platform-bible-utils/src/comments.types.ts Outdated
@lyonsil

lyonsil commented Jul 8, 2026

Copy link
Copy Markdown
Member

Regarding the review items:

  1. The C1 fix: confirm refusing merge (vs. silently degrading to accept/reject) is the desired behavior when the two sides overlap.
  • I think you were talking through this with Sebastian. I'm not as familiar with all the various ways this can go, but I trust that if you've aligned with him, we're good.
  1. I1: confirm the intent to require standing CanEdit(book, chapter) for non-admin resolvers (this tightens behavior vs. what the PR originally shipped and its original test asserted).
  • This aligns to P9 behavior and looks good.
  1. M2 follow-up: decide whether to add per-comment resilience to PlatformCommentConverter.Write so a single malformed conflict can't fail the whole getCommentThreads response.
  • This was flagged in the review comments from /code-review. Seems like a general code quality change to make if it's not a lot of trouble.
  1. Landing order: PT-4029: feat(comments): resolveConflict (accept/reject) write backend for verseText merge conflicts #2495 should not reach end-users ahead of pt-4030 (see C2), and pt-4030 needs a small follow-up to import ConflictResolutionOptions from platform-bible-utils (see I2).
  • You're managing this.
  1. Full npm run typecheck in a standard checkout (see the typecheck caveat).
  • CI handles this.

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
rolfheij-sil added a commit that referenced this pull request Jul 9, 2026
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
rolfheij-sil and others added 2 commits July 9, 2026 17:12
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 lyonsil left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

:lgtm:

@lyonsil reviewed 11 files and all commit messages, made 1 comment, and resolved 14 discussions.
Reviewable status: :shipit: complete! all files reviewed, all discussions resolved.

@rolfheij-sil
rolfheij-sil merged commit 8a44e2d into main Jul 9, 2026
6 of 7 checks passed
@rolfheij-sil
rolfheij-sil deleted the pt-4029-resolve-conflict branch July 9, 2026 18:00
rolfheij-sil added a commit that referenced this pull request Jul 10, 2026
…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>
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