PT-4036/PT-4037: versification-aware synchronized scrolling#2478
Conversation
…ectDataProvider (PT-4036)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…T-4037) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…gnostic PAPI command Move the versification conversion off the per-project Versification PDP and re-host it as `command:platformScripture.mapVerseRefBetweenProjects` via VersificationConversionService. The per-project host forced consumers to acquire their own project's PDP (~20s then throws for projects not implementing the interface); a command needs no acquisition. - New VersificationConversionService registers the command with experimental OpenRPC docs - Program.cs wires it into the startup Task.WhenAll block - ParatextProjectDataProvider: remove MapVerseRefBetweenProjects method and registration - VersificationConversionServiceTests: rename + repoint from PDP to service; add HasMultiple bridge test, segment-preserved test, and unmapped best-effort comment case - platform-scripture.d.ts: move declaration from IVersificationProjectDataProvider to CommandHandlers (string | undefined per platform policy); add SerializedVerseRef import Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Session-URL: https://claude.ai/code/session_01DyZeT3Mcy2WmGrkuCtnRaz
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DyZeT3Mcy2WmGrkuCtnRaz
…nd instead of PDP Removes PDP acquisition (useProjectDataProvider + VersificationPdp type) and calls platformScripture.mapVerseRefBetweenProjects via sendCommand. Eliminates the 20 s hang caused by acquiring the versification PDP. Pass-through conditions simplified to !projectId || source===target. Adds two missing pass-through tests (source===target, error fallback) and @param sourceProjectId TSDoc (#5, #7). Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Session-URL: https://claude.ai/code/session_01DyZeT3Mcy2WmGrkuCtnRaz
…e spacing Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Session-URL: https://claude.ai/code/session_01DyZeT3Mcy2WmGrkuCtnRaz
…e-verse writes Persist scrRefSourceProjectIds alongside scrRefs so the versification frame survives reload (#3), and update setScrRefSync's early-return so a same-numbered ref set by a different project refreshes the source without clobbering a known source on the platform.verseRef echo (#2). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BpJJa1ZLtWPbavLG9msavF
…th non-scripture fallback Convert a scroll group's reference into a consumer project's versification via one cached service method (#8, #9) exposed on the network object so any consumer gets a usable ref instead of the raw source-versification ref (#10). Conversion failures for non-versification targets fall back to the raw ref and are logged once, not re-fired every navigation (#4). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BpJJa1ZLtWPbavLG9msavF
… fix detach to use displayed ref The hook now delegates conversion to the cached scroll-group boundary method through usePromise instead of calling the command directly, dropping the local cast shim (#11, #12). Detaching from a scroll group now seeds the independent ref with the displayed (converted) value rather than the raw group ref, so the verse no longer jumps (#1). The no-conversion path returns the ref synchronously to avoid a one-frame lag (#6), and the host cache yields a stable identity so unchanged refs don't force re-renders (#7). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BpJJa1ZLtWPbavLG9msavF
…hangeVersification VerseRef.ChangeVersification already dispatches to ChangeVersificationWithRanges internally when the ref HasMultiple (libpalaso), so the explicit branch was duplication (#13). Behavior unchanged; existing tests cover both paths. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BpJJa1ZLtWPbavLG9msavF
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BpJJa1ZLtWPbavLG9msavF
… conversion Address code-review feedback on the versification-aware scroll sync: - VersificationConversionService: make mapVerseRefBetweenProjects best-effort. A null source frame is passed through unchanged (not assumed English), and an unresolvable project versification returns the reference unchanged instead of throwing (new TryGetVersification). - scroll-group host: track each project's current versification via a setting subscription and fold it into the conversion cache key, so a mid-session versification change yields a fresh conversion; skip the round-trip when source and target share a versification. - scroll-group host: bound the conversion cache, coalesce in-flight identical conversions, and add getScrRefForProjectSync for a no-flash initial/detached display; stop permanently suppressing a project on transient failure. - useScrollGroupScrRef: seed usePromise with the synchronously-known conversion and gate the async conversion so an independent ref never converts the wrong scroll group. - Regenerate papi.d.ts / platform-scripture.d.ts TSDoc. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Correctness fixes for versification-aware synchronized scrolling: - Re-run the display conversion when a followed group's source project changes for the same book/chapter/verse (convertScrRef now depends on the source frame; compareScrRefs is versification-blind so the numbers alone look unchanged). - Re-seed a web view detached mid-conversion with the resolved conversion so its now-independent ref is not frozen in the source project's frame. - Guard the C# ChangeVersification call so an unmappable ref honors the documented best-effort "return unchanged" contract instead of throwing. - Skip the redundant platform.verseRef write (which fans out app-wide) and the second localStorage write on a source-only change to scroll group 0. Cleanup / clarity: - Memoize the conversion seed (was serializing per render). - Extract a shared planConversion helper so the sync and async readers can't drift on the no-conversion gate or cache key. - Document getScrRefSourceProjectIdSync (API surface) and the raw-frame semantics of getScrRef / onDidUpdateScrRef; regenerate papi.d.ts. Mid-session versification change is intentionally left out of scope (a project's versification is set at creation and effectively never changes); documented in-code. Adds regression tests for the source-change re-conversion, the detach re-seed, and the platform.verseRef write gating. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
rolfheij-sil
left a comment
There was a problem hiding this comment.
Code review: versification-aware synchronized scrolling
Reviewed the new versification-aware sync in scroll-group.service-host.ts and use-scroll-group-scr-ref.hook.ts. The overall design holds together well — the shared planConversion gate keeping the sync and async paths from drifting, the versification-keyed conversion cache with in-flight de-duplication, and the seed-synchronously-then-correct-asynchronously pattern are all nicely thought through.
Seven things worth a look before merge (weight + type in the table, details inline). #2 and #3 aren't flat defects — they hinge on assumptions about how versification and project-less drivers behave in practice, so they're really questions about the intended contract.
| # | Location | Weight | Type | Issue |
|---|---|---|---|---|
| 1 | scroll-group.service-host.ts:289 |
🟠 High | Bug | Same-versification fast path keys off the base ScrVersType only; projects sharing a base type but differing in custom.vrs skip conversion and mis-frame — a silent, persistent wrong verse in the mixed-versification case this feature targets. |
| 2 | scroll-group.service-host.ts:198 |
🟠 High* | Clarification / decision | A mid-session versification change refreshes the cache but never re-emits, so an on-screen reference stays mapped under the old versification indefinitely. Rests entirely on "versification never changes during a session." |
| 3 | scroll-group.service-host.ts:458 |
🟡 Medium* | Clarification / decision | A numbers-changed write from a project-less caller stamps sourceProjectId = undefined, wiping a known frame so other followers stop converting. |
| 4 | use-scroll-group-scr-ref.hook.ts:223 |
🟡 Medium | Bug | The post-detach async re-seed guards against the stale detach-time ref, so a navigation made during the conversion round-trip is silently reverted — a lost user action. |
| 5 | use-scroll-group-scr-ref.hook.ts:167 |
🟡 Medium | Transient UX | usePromise seed is the raw source-frame ref on a cache miss with preserveValue: false, so forward navigation flashes the unconverted verse (frequent — every uncached move). |
| 6 | use-scroll-group-scr-ref.hook.ts:160 |
🔵 Low | Transient UX | conversionSeed memo omits sourceProjectIdLocal, so a source-only authority flip briefly shows the reference converted from the previous source (rare). |
| 7 | scroll-group.service-host.ts:256 |
⚪ Minor | Cleanup | conversionCacheKey serializes the reference twice per navigation (the sync-seed and async paths each build the key). |
* Conditional — see the inline note. #2 only bites if versification can actually change mid-session; #3 only if a project-less driver can share a scroll group with a differently-versified editor. Neither rises to Critical — all seven are recoverable display issues, no data loss or crash.
Details inline. Happy to discuss any of them.
| if ( | ||
| sourceProjectId === undefined || | ||
| sourceProjectId === projectId || | ||
| (sourceVersification !== undefined && sourceVersification === targetVersification) |
There was a problem hiding this comment.
🟠 High · Bug
Same-versification fast path ignores custom.vrs
This disjunct skips conversion whenever sourceVersification === targetVersification. But those identifiers come from the platformScripture.versification setting, which is the base ScrVersType (a number, default 4) — not the project's actual custom.vrs content. Two projects can both report English (4) yet have custom versifications that merge or split verses differently.
When that happens, planConversion returns needsConversion: false on both the sync and async paths, so getScrRefForProject returns the raw source-frame reference unconverted and a follower lands on the wrong verse — a stable error until the next navigation, in exactly the mixed-versification case this feature exists to handle.
Is the versification identifier guaranteed to capture custom.vrs differences, or is it really just the base type? If it's the base type, this fast path isn't a safe conversion-skip — it would need the actual versification identity, or should just let the C# round-trip decide.
There was a problem hiding this comment.
Fixed in 285f2c6 (with a stale-comment + papi.d.ts follow-up in 782548b). Confirmed the platformScripture.versification setting only carries the base ScrVersType (default 4), which a customized versification keeps (ScrVers.IsCustomized) — so two projects reporting the same identifier can still convert differently, and equal identifiers can't prove custom.vrs equality. Removed the disjunct from planConversion; the gate is now just unknown-source or source == target. The C# command decides for every cross-project pair (a genuinely-identical versification is a cached no-op), and the versification identifiers still key the conversion cache so a mid-session change re-keys.
| await versificationPdp.subscribeSetting( | ||
| 'platformScripture.versification', | ||
| (value) => { | ||
| projectVersifications.set( |
There was a problem hiding this comment.
🟠 High (if the assumption is wrong) · Clarification / decision
A mid-session versification change updates the cache but never re-emits
This callback refreshes projectVersifications (keeping the fast path and the cache key fresh) but, per the comment just below, intentionally does not re-emit onDidUpdateScrRef. So a reference already on screen keeps displaying the verse mapped under the old versification until the user manually navigates — a stationary reader sees the wrong verse indefinitely.
The whole behavior rests on the stated premise that "a project's versification is chosen at creation and effectively never changes during a session." The platformScripture.versification setting is user-editable, though. If versification can in fact change mid-session (and other parts of the app react to it), this premise doesn't hold and the on-screen reference should re-convert rather than silently going stale.
Can you confirm whether that assumption is actually guaranteed? If it isn't, this needs to re-emit on change.
There was a problem hiding this comment.
Confirmed the assumption isn't guaranteed — the platformScripture.versification setting is user-editable — so we handled it rather than relying on it (eab4257 host, e3438de hook). The host now emits an internal onDidChangeVersification event only on a genuine mid-session change (not the initial subscription load), and each consumer bumps a generation token that re-runs its conversion once. Blunt by design (every consumer re-converts on any versification change), which is fine because the change is rare and deliberate, and it avoids adding a per-consumer versification subscription. The event is host↔hook internal (not on the public NetworkEvents/service surface).
| const sourceProjectIdChanged = scrRefSourceProjectIds[scrollGroupIdDefaulted] !== sourceProjectId; | ||
| scrRefs[scrollGroupIdDefaulted] = scrRefClone; | ||
| saveScrRefs(); | ||
| scrRefSourceProjectIds[scrollGroupIdDefaulted] = sourceProjectId; |
There was a problem hiding this comment.
🟡 Medium (if reachable) · Clarification / decision
A numbers-changed write from a project-less caller wipes a known source-project id (only bites if a project-less driver can share a group with a scripture editor)
On a numbers-changed write, scrRefSourceProjectIds[...] is set unconditionally to the caller's sourceProjectId. A driver with no project — e.g. a resource or dictionary panel whose saved web-view definition has no projectId — passes undefined, overwriting a previously-known source frame with undefined. After that, getScrRefForProject sees an unknown source and returns the raw reference, so a differently-versified follower stops converting and jumps to the raw verse, staying wrong until some project-bearing editor navigates the group again.
The no-op guard above deliberately lets a same-numbered / different-source write through (correct — the frame changed). But on a numbers-changed write whose incoming source is undefined, should the stored source id be cleared, or preserved? Preserving it when sourceProjectId === undefined would keep the known frame intact.
Flagging as conditional since it only matters if a project-less driver actually shares a scroll group with a differently-versified editor — let me know if that's reachable in practice.
There was a problem hiding this comment.
This is by design. A write with no source project means the versification frame is genuinely unknown, so followers take the raw reference rather than mis-frame it under the previous source — the same deliberate passthrough as the platform.verseRef echo. It is reachable (a project-less web view sharing a group), but the pass-through-unconverted behavior is intended for an unknown frame. Made the contract explicit in-code in 66adcf2 (no behavior change).
| scrollGroupIdLocalRef.current === undefined && | ||
| compareScrRefs(converted, displayScrRef) !== 0 | ||
| ) | ||
| setScrollGroupScrRefRef.current(converted); |
There was a problem hiding this comment.
🟡 Medium · Bug
Post-detach re-seed can silently revert a user navigation
This guard checks only that the pane is still detached (scrollGroupIdLocalRef.current === undefined) and that the converted ref differs from the detach-time displayScrRef captured in the closure. It never checks whether the user has moved the pane since detaching.
Scenario: detach a pane at a verse whose conversion isn't cached (first visit). Line 206 seeds the raw source-frame ref and this IIFE fires the async conversion. Before it resolves, the user scrolls the now-independent pane to another verse. When the conversion lands, compareScrRefs(converted, displayScrRef) is still non-zero (converted ≠ the old detach-time verse), so setScrollGroupScrRefRef.current(converted) overwrites the user's new position and snaps the pane back to where it was detached — their navigation is lost.
The re-seed wants to fire only if the independent ref is still the one it seeded. Comparing the pane's current independent ref against the seeded displayScrRef (bail if the user has since changed it), rather than comparing converted against the stale displayScrRef, would close this.
There was a problem hiding this comment.
The race is real in theory but needs two human actions — detach, then navigate the now-independent pane — both inside the millisecond conversion round-trip. A followed verse's conversion fires when the group ref changes (via usePromise), so by the time a user can operate the scroll-group selector it has already resolved and cached, and the async re-seed block isn't even entered in practice. Rather than harden an unreachable path, we deleted the async re-seed (7802b6b): detach now just keeps the currently-displayed (correct-frame) verse, and the residual is exactly the unreachable race, where the pane keeps what it was showing — recoverable, no persisted write.
| : scrRefLocal, | ||
| [isConversionRequired, projectId, scrRefLocal], | ||
| ); | ||
| const [convertedScrRef] = usePromise( |
There was a problem hiding this comment.
🟡 Medium · Transient UX
Forward navigation flashes the unconverted verse
On a cache miss, getScrRefForProjectSync (the conversionSeed on the line above) returns the raw source-frame reference, and with preserveValue: false the display resets to that seed while the async conversion is in flight. Forward navigation to a new reference is always uncached, so a differently-versified follower renders the source verse (e.g. Ps 147:1) for one round-trip before snapping to the correct verse (Ps 146:1) — a wrong-verse flash on essentially every move to a new reference.
There's a genuine tradeoff here: preserveValue: true would instead linger on the previous converted verse until the new one resolves (old position rather than wrong-numbered verse), or the mixed-versification case could withhold rendering until conversion completes. Each trades the flash for a different transient — worth deciding which is least jarring for readers.
There was a problem hiding this comment.
Switched to preserveValue: true (a019bfb): the pane now lingers briefly on the previous converted verse while the next conversion is in flight, instead of flashing the raw source-frame verse. Judged the less jarring transient for readers, and it's first-visit-only (cached after). A true withhold-render would have meant adding an isConverting flag to the hook's return, updating every consumer, and giving the editors a loading affordance — not worth it for this transient.
| // the cache-key serialization inside getScrRefForProjectSync runs once per verse change rather than | ||
| // on every render (usePromise only reads this seed when the factory changes). A source-only change | ||
| // does not recompute the seed, but convertScrRef re-runs and corrects the displayed value. | ||
| const conversionSeed = useMemo( |
There was a problem hiding this comment.
🔵 Low · Transient UX
conversionSeed memo omits sourceProjectIdLocal from its deps
The deps are [isConversionRequired, projectId, scrRefLocal] — sourceProjectIdLocal isn't listed. On a source-only change (same verse numbers set by a different-versification project), the memo isn't recomputed, so with preserveValue: false the display briefly resets to the seed computed from the previous source project's frame (a different verse) until convertScrRef re-runs and corrects it — a one-round-trip flash on authority flips.
convertScrRef already lists sourceProjectIdLocal as a dependency (line 152); adding it here would keep the seed consistent with the factory. The comment just above (lines 158–159) acknowledges the seed doesn't recompute on a source-only change — this is that gap made concrete.
There was a problem hiding this comment.
Fixed in d6f3f86 — added sourceProjectIdLocal to the conversionSeed memo deps so the seed recomputes on a source-only change, consistent with convertScrRef.
| targetVersification: string | undefined, | ||
| scrRef: SerializedVerseRef, | ||
| ): string { | ||
| return `${sourceProjectId}|${sourceVersification ?? ''}->${targetProjectId}|${targetVersification ?? ''}:${serialize(scrRef)}`; |
There was a problem hiding this comment.
⚪ Minor · Cleanup
serialize(scrRef) runs twice per navigation
conversionCacheKey is built independently by both callers of planConversion — getScrRefForProjectSync (the sync seed) and getScrRefForProject (the async path) — so the same reference is serialized twice on every synchronized navigation, for every follower. Not a correctness issue, just repeated work on the sync-scroll hot path. If it's worth it, threading the plan (or its key) from the sync computation into the async call would avoid the second serialize.
There was a problem hiding this comment.
Declining this one. Threading the key from the sync path into the async call would recouple the two paths that the shared planConversion deliberately keeps parallel (a divergent gate/key shape is exactly what would make the sync seed miss the async-written cache and reintroduce a flash). The cost is one small serialize of a verse-ref object per navigation — not worth the coupling.
The platformScripture.versification project setting carries only the base ScrVersType, not custom.vrs content, so two projects reporting the same identifier can still convert differently. Skipping the conversion round-trip on that match was unsound. Remove the skip and let the C# command decide; a genuinely-identical versification is a cached no-op round-trip. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…eview #1) The base-versification fast path was removed in 285f2c6, but several comments (including the public getScrRefForProject JSDoc that flows into papi.d.ts) still described it. Correct all of them: the only no-conversion cases are an unknown source frame or a source already equal to projectId. The tracked versification identifier's sole remaining purpose is to key the conversion cache. Regenerated papi.d.ts to match the public JSDoc. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#3) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e (review #2) Add a host-internal onDidChangeVersification network event that fires only when a tracked project's versification actually changes mid-session (not on the initial subscription load). The hook side that consumes this event to re-convert on-screen references is a separate follow-up task. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…#2) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nal event Reflects the two new internal exports from the versification-change signal (review #2): EVENT_NAME_ON_DID_CHANGE_VERSIFICATION and onDidChangeVersification. Added only to the scroll-group service-model / service-host module declarations, mirroring the existing internal onDidUpdateScrRef sibling; NOT added to the public NetworkEvents map or IScrollGroupService. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- host test: drop '(fast path)' from the projectVersifications mock note (#1 removed it) - hook test: usePromise mock header now describes the preserveValue:true linger (#5) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
rolfheij-sil
left a comment
There was a problem hiding this comment.
Re-review: round two
Thanks for the thorough turnaround on round one — dropping the fast path, the versification-change event, and the seed recompute all land well. I re-reviewed the full PR at the new head (96740ef). Two things block, plus one opportunistic fix and one doc line — my next pass will be scoped to the two blockers only, not another full sweep, so we can end the review loop here.
Blocking
1. c-sharp/Projects/VersificationConversionService.cs:69 — 🔴 Crash: every "return unchanged" fallback path NREs during response serialization.
A typical SerializedVerseRef has no versificationStr, so VerseRefConverter.Read builds a VerseRef with a null Versification. The fallback paths (null sourceProjectId, unresolvable project, conversion failure) return that ref, and VerseRefConverter.Write dereferences value.Versification.Type → NullReferenceException. So the command errors in exactly the cases the d.ts documents as "returned unchanged rather than throwing." The NUnit tests call the method directly and never serialize the result, which is why they pass.
Two ways out: (a) minimal — return the input SerializedVerseRef as-is on the fallback paths; (b) my preference — throw on failure instead of silently passing through. The TS caller's .catch already falls back to the raw ref and doesn't cache, so (b) also fixes a second latent issue for free: today's silent passthrough is indistinguishable from a genuine identity conversion, so getScrRefForProject caches a transient failure permanently (scroll-group.service-host.ts:437) — despite the adjacent .catch comment saying failures must not be latched. (b) needs the d.ts contract wording updated to match.
2. use-scroll-group-scr-ref.hook.ts:193 — 🟠 Regression: preserveValue: true displays stale verses, and the new conversionSeed recompute is display-inert.
usePromise reads defaultValue only in its mount initializer, and with preserveValue: true it never resets — so the recomputed seed added in d6f3f86 is never actually displayed after the first render. When isConversionRequired toggles false→true (source authority moves between differently-versified projects), the hook shows usePromise's last resolved conversion from an earlier phase. Repro: panes A/B share group 0 → navigate A to R1 (B shows conv(R1)) → navigate in B to R2 → navigate A to R3 → B first visibly jumps back to conv(R1) before landing on conv(R3); a detach during that window persists the stale ref into the web view definition. Fix direction: make the display fall back to the recomputed conversionSeed instead of usePromise's frozen internal value — which also makes the seed machinery you built actually execute.
While you're in there (non-blocking)
3. use-scroll-group-scr-ref.hook.ts:235 — the "unreachable" justification for removing the detach re-seed doesn't hold on the conversion-failure path: convertScrRef catches, falls back to the raw source-frame ref, caches nothing, and nothing retries until the user navigates. A detach in that state persists an unconverted ref into the web view definition — permanently, across restarts. A guarded re-seed (only applied if the user hasn't navigated since detach) would fix this and round one's lost-navigation defect at the same time. Fixing #2 shrinks this window a lot, so treat it as opportunistic.
4. Doc line — the narrowed no-op guard (scroll-group.service-host.ts:493) means onDidUpdateScrRef now fires on same-numbers, source-only writes. Worth one sentence in the event's jsdoc so external consumers don't keep assuming "fires only when the verse actually changed."
Details available on any of these. #1 and #2 are the only things between this and approval from my side.
…splay) Round-two review (rolfheij-sil) plus /code-review max on the response. Blocking: - C# NRE: VersificationConversionService fallback paths returned a null-versification VerseRef that NRE'd in VerseRefConverter.Write during JSON-RPC serialization. Now throw VersificationConversionException on genuine failures (unresolvable named source/target, mapping failure) and keep the null-source passthrough. Harden VerseRefConverter.Write to null-guard Versification. Throwing also fixes the cache-latch: the caller's .catch falls back to the raw ref and does not cache a transient failure. - Stale display: the hook showed usePromise's frozen internal value while preserveValue:true kept it, and the conversionSeed recompute was display-inert. Now linger on the last-displayed ref, tagging each async conversion with the inputs it was computed for (verse, source, target, versification generation) and showing it only while current; otherwise the last-displayed verse, or the sync seed at cold start. Non-blocking: - Detach: document that a detach during a genuine conversion failure persists the best-effort raw ref (unavoidable when no conversion is available). - onDidUpdateScrRef jsdoc: note it also fires on same-numbers, source-only writes. From /code-review max: - Fix: the conversion tag omitted projectId, so a target-project change could briefly display the conversion into the previous target's versification. - Assert the thrown exception's SourceProjectId/TargetProjectId; add tests for detach-mid-linger and failure-mid-linger; include the failing ref in the mapping-failure exception message. Update the mapVerseRefBetweenProjects d.ts contract to document the throw and regenerate papi.d.ts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ut4wdmZqp3h6WFcmbvbZ8z
The versification-conversion command now rejects with a PlatformError (a plain
object), so the prior `${e}` interpolation rendered "[object Object]" and lost
the message. Use platform-bible-utils' getErrorMessage(e), matching the pattern
in theme/window/data-provider service hosts, so the warning carries the actual
message (which embeds the C# VersificationConversionException text).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ut4wdmZqp3h6WFcmbvbZ8z
…c logs
Sweep the two remaining ${errorObject} interpolations in the platform.verseRef
sync path (settingsService.set catch and the subscribe handler's PlatformError)
to getErrorMessage, so every error log in this file renders the message instead
of "[object Object]".
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ut4wdmZqp3h6WFcmbvbZ8z
|
Thanks for the thorough round two. Both blockers are fixed, along with the two non-blocking items and one extra issue a self-review turned up. Pushed to #1 — C# NRE + cache-latch — fixed. Went with your option (b). The fallback paths now throw #2 — stale display / inert seed — fixed. The display now lingers on the last-displayed ref: each async conversion is tagged with the inputs it was computed for (verse, source, target, versification generation) and shown only while current; otherwise it lingers on the verse that was last on screen, falling back to the sync seed only at cold start. That removes the visible jump back to #3 — detach re-seed — documented, not re-added. Per your "opportunistic / #2 shrinks the window" note, I did not re-introduce the post-detach async re-seed. #2 makes the detach seed the last-displayed (correctly-framed) value; the only residual is a detach during a genuine conversion failure, where the raw ref is the honest best-effort (a re-seed would fail identically). Documented that at the detach site. #4 — doc line — done. Added a sentence to Bonus (self-review): the conversion tag built for #2 initially omitted the target Two things I considered and left as-is, happy to revisit either:
AI-assisted — session |
Automated re-review at
|
|
Found some none blocking things. |
rolfheij-sil
left a comment
There was a problem hiding this comment.
@rolfheij-sil reviewed 16 files and all commit messages, and resolved 7 discussions.
Reviewable status:complete! all files reviewed, all discussions resolved.
|
From Claude for 1 - same-versification round-trip lag - you're right, accepting it; wording corrected. Agreed, and the round-two note overreached. "Invisible except where versifications diverge" is wrong: because isConversionRequired gates on project identity, not versification identity, two different-project followers always take the async path even when their versifications are identical. So for the primary case - two same-base-versification editors in one scroll group - every navigation to an uncached verse shows the prior verse for one mapVerseRefBetweenProjects round-trip before catching up. That's the common case, not an edge. It stays as-is by design. The display order (currentConversionRef ?? lastDisplayedRef.current ?? conversionSeed) deliberately prefers lingering on the last-displayed verse over the synchronous seed, because for divergent versifications that seed is the raw source-frame ref - the wrong verse - and preferring it is exactly the flash the round-two stale-display fix removed. There's no synchronous versification-equality signal to tell the two cases apart (that was the fast path removed in round two), so both pay the same one-round-trip linger. I'm accepting that cost: the lag is bounded to a single round-trip, self-corrects on arrival, never displays a wrong verse, and the cache warms so revisits are instant. Recovering the same-versification case would mean reintroducing a synchronous same-versification check, which reopens the divergent-versification flash. The code comment at the display line already frames this honestly ("sluggish-but-stable beats flashing a wrong-versification verse"); only the PR note claimed invisibility. No code change - flagging the corrected wording for the record. For 2 - I'm not worried about this particular window. If the user is navigating and that method hasn't registered yet at startup, there are much larger problems happening in the application. For 3 - This is a very rare case (modifying a custom versification mid-session). Not worth digging into right now. For 4 - This is minor and not worth extending the PR. |
Building on #2478's versification-aware sync, the main toolbar adopts the active scripture tab's numbered scroll group and renders its BCV in that tab's versification (via the #2478 projectId), keeping the last group when the active tab has none. Adds a toolbar-internal useActiveTabScrollGroup hook that exposes the last active scripture tab's scroll group + project from window focus and the WebView definition (ignoring transient non-webview focus), deriving the active-tab definition synchronously so webViewId and its definition update in the same render. Includes the design spec and implementation plan. Treats a project-bound tab with no persisted scrollGroupScrRef as the default scroll group 0 (a freshly-opened reader/editor persists none until the user changes its group), so the toolbar recognizes it as attached and adopts its project for versification framing instead of falling back to the group's stored source. Non-project tabs and detached (object) refs stay unattached, so the toolbar keeps its last group. Also stops the scroll-group service from reprocessing its own platform.verseRef mirror echoes. Every group-0 verse change is mirrored out to that setting; when a toolbar navigation and the editor's scroll-settle write raced, a stale setting echo arrived after the stored ref had moved on and — carrying no source frame — cleared the known versification source, so followers stopped converting and both panels showed the same raw reference. The subscription now recognizes and skips the values it just mirrored, breaking the feedback loop; genuine external verseRef writers still flow through. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V8KSmVvarWfKxtutZhpRyA


Summary
Implements Row 4 of PT-4031 ("Windows scroll together, versification-aware"), subtasks PT-4036 and PT-4037.
When windows in a scroll group span projects with different versifications, a shared
book/chapter/versepreviously landed each window on the same numbers but, at divergence points (Psalm headings, 3 John, deuterocanon, Greek/Hebrew numbering), a different actual verse. This adds versification-aware conversion so each consumer displays the corresponding verse in its own project's versification — porting the PT9 pattern.Changes
platformScripture.mapVerseRefBetweenProjects(verseRef, sourceProjectId, targetProjectId)(c-sharp/Projects/VersificationConversionService.cs, registered inProgram.cs), wrapping the existingVerseRef.ChangeVersification/ChangeVersificationWithRanges.nullsource ⇒ canonical English. Marked experimental via the registration documentation arg. Declared TS-side inCommandHandlers.projectIdof its current ref (scroll-group.service-model.ts/.service-host.ts, session-only).useScrollGroupScrRefgains aprojectIdand converts the followed ref into the consumer's versification via the command (display-only, pass-through-safe, stale-guarded). The three internal callers pass theirprojectId(web-view wrapper, web-view host, toolbar viacurrentProject); the 11 extension consumers are unchanged.Design note
The conversion is hosted as an Individual Command (not a per-project data provider): it takes both project IDs and is project-agnostic, so hosting it per-project would force every consumer to acquire its own project's Versification PDP — which hangs for projects that don't implement that interface. A command needs no acquisition. Per-project versification lookups (
getFinalVerseNumber, etc.) correctly stay on theplatformScripture.Versificationinterface.Testing / verification
npm run typecheck, lint (changed files),npm test(all suites, 0 failed incl. storybook),dotnet test(1294 passed / 0 failed / 6 skipped) — all green.HasMultiple/bridge branch) and new TS host + hook tests (conversion + pass-through branches).PSA 51:1(English) →PSA 51:3(Original) with real.vrsdata; in the UI, two projects in one scroll group sync to Psalm 51 with the toolbar showing the English-converted verse (51:1) while the group holds the Original verse (51:3) — cross-versification conversion confirmed end-to-end.Known follow-up (separate, not this slice)
Navigating a scroll group to a book a consumer's project doesn't contain can trigger a book-corrupting write-back from the scripture editor's own (pre-existing) missing-book handling — observed only in a confounded multi-consumer state, independent of this conversion (the command preserves the book; reproduces with no conversion running). This is the separate editor-WebView concern PT-4031 anticipated; recommend a separate ticket. PT-4036/PT-4037 ticket text to be reconciled to the command-based design.
AI Involvement
AI-assisted — session. Brainstorming → spec → plan → subagent-driven implementation with per-task review, a whole-branch review, and human-gated design decisions (including re-hosting the command). All code was human-reviewed.
Risk level
Low — additive command + display-only conversion with pass-through fallbacks; degrades safely; the 11 extension consumers are unchanged; backward-compatible with old persisted scroll-group state.
🤖 Generated with Claude Code
https://claude.ai/code/session_01DyZeT3Mcy2WmGrkuCtnRaz
This change is
Code Review Summary
Branch: pt-4036-versification-aware-sync
Base: origin/main
Date: 2026-07-01
Review model: Claude Opus 4.8
Files changed: 13
Overview
This branch makes scroll groups (synchronized scrolling) correct across projects that use
different versifications. Previously a scroll group stored a single Scripture reference and every
follower consumed it verbatim; a follower whose project used a different versification could land on
the wrong verse. The change tracks, per scroll group, the id of the project whose versification the
stored reference is expressed in ("source project"), persists it alongside the reference, and
converts the reference into each consumer's versification on read.
The conversion itself is a new project-agnostic PAPI command,
platformScripture.mapVerseRefBetweenProjects(verseRef, sourceProjectId?, targetProjectId),implemented in C# core (
VersificationConversionService) by grounding the reference in the sourceproject's
ScrVersand delegating to libpalaso'sChangeVersification. It is best-effort: anunknown source frame (null) or an unresolvable project returns the reference unchanged rather than
throwing. On the renderer side,
scroll-group.service-hostadds cachedgetScrRefForProject/getScrRefForProjectSync(bounded session cache keyed on live versification identifiers, in-flightcoalescing, per-project versification subscription), and
useScrollGroupScrReftakes an optionalconsumer
projectId, converting the followed reference for display viausePromise(synchronouscached seed + async correction) with careful detach re-seeding. Consumers (toolbar, web view) pass
their project id.
API Changes
@papi/core:ScrollGroupUpdateInfo— added optionalsourceProjectId?: stringfield.scrollGroupService(IScrollGroupService,@papi/backend/@papi/frontend):setScrRef()—added optional trailing
sourceProjectId?: stringparameter.scrollGroupService: added methodgetScrRefForProject(scrollGroupId, projectId): Promise<SerializedVerseRef>.scrollGroupService:getScrRef()andonDidUpdateScrRef— documentation clarified only; nosignature change (
getScrRefstill returns the raw stored reference).@papi/frontend/react:useScrollGroupScrRef()— added optional trailingprojectId?: stringparameter.
platform-scripture.d.ts(papi-shared-typescommand): addedplatformScripture.mapVerseRefBetweenProjects(verseRef, sourceProjectId, targetProjectId): Promise<SerializedVerseRef>,marked
@experimental.lib/platform-bible-react,lib/platform-bible-utils: no changes.All public changes are backward-compatible: optional parameters are appended at the end of existing
signatures, the new
ScrollGroupUpdateInfofield is optional, andgetScrRefForProjectis purelyadditive. Existing two-arg
setScrRef/ no-arggetScrRefcallers are unaffected.Findings
Critical — Must address before merge
react-hooks/exhaustive-depserror onconvertScrRefinsrc/renderer/hooks/papi-hooks/use-scroll-group-scr-ref.hook.ts— the callback referenced thederived
isConversionRequiredconst but did not list it, which the project config treats as anerror (not a warning), so a clean CI checkout running
eslint .would fail. This waspre-existing in the branch and not surfaced by the four analysis passes (they do not run lint); it
was found during the post-change quality check. (fixed during review: inlined the gate condition
if (!projectId || sourceProjectIdLocal === projectId) return scrRefLocal;so exhaustive-deps seesthe real reactive inputs directly — no suppression, behavior identical, and the load-bearing
sourceProjectIdLocaldependency is preserved. AddingisConversionRequiredto the deps insteadwould make the rule then demand removing
sourceProjectIdLocal, which drives the source-onlyre-conversion. Verified: typecheck + lint clean.)
[Author response: Author chose the inline-guard fix over a justified
eslint-disable. Note for thereviewer: this was pre-existing in the branch; the local worktree could not run
eslint .nativelybecause it is nested under the parent repo (plugin-resolution ambiguity), so the error was only
visible via
--resolve-plugins-relative-to .. Worth confirming CI is green after this fix.]Important — Should address before merge
src/renderer/services/scroll-group.service-host.ts(ensureVersificationSubscribed) — thecatchblock deletes the subscription entry so a project is not latched off for the session and alater call retries; the service-host tests always mocked
pdpGetto succeed, so this deliberatelyresilient branch was never exercised. (fixed during review: added a test that rejects the first
subscribe attempt per project then succeeds, using the same-versification fast path as observable
proof that the failed entry was cleared for retry. Verified as a revert test — neutralizing the
deletemakes it fail.)[Author response: Author asked for the test to be added during the interview.]
Minor — Consider
isConversionRequirednamed like a boolean but not one inuse-scroll-group-scr-ref.hook.ts— theis-prefix implies boolean, butprojectId && ...evaluated to
string | boolean | undefined. (fixed during review: changed to!!projectId && ..., a true boolean. Deliberately NOTBoolean(projectId)— verified with anisolated tsc check that
Boolean(...)drops TypeScript's control-flow narrowing ofprojectIdtostringat thegetScrRefForProject/getScrRefForProjectSynccall sites, whereas!!projectIdpreserves it.)
cacheConversioneviction untested inscroll-group.service-host.ts— the drop-oldestbound at
CONVERSION_CACHE_MAX_SIZE = 1000had no coverage. (fixed during review: added a testthat fills to the cap + 1 and asserts the oldest entry is evicted while a newer one survives.
Verified as a revert test. Side note surfaced while writing it: verse numbers > 999 saturate the
BBBCCCVVVencoding used bycompareScrRefs, so the test generates distinct refs by varyingchapter+verse within valid field ranges — a test-data concern only, not a product issue.)
C#
InitializeAsync(command registration) untested inVersificationConversionService.cs— only
MapVerseRefBetweenProjectswas unit-tested, leaving the wire-name string and parameterdocs unverified. (fixed during review: added a test asserting the command registers under its
exact wire name
command:platformScripture.mapVerseRefBetweenProjects, is marked experimental, anddocuments the three positional params
verseRef,sourceProjectId,targetProjectIdin order.)(Author opted not to change;setScrRefSyncinserts the new optionalsourceProjectId?parameter before the existingoptional
shouldSetVerseRefSetting, shifting a positional slotanalyzers flagged it as low-risk — the export is renderer-internal, not a
@papi/surface, and allfour in-repo callers were updated to the new arity. Appending would have been marginally less
fragile for future edits.)
(Author opted not to change; analyzer noted this is auseScrollGroupScrRefnow carries three async-adjacent branches (sync seed + async displayconversion + async detach re-seed) at the upper edge of single-hook readability; consider
extracting the detach re-seed into a helper
judgment call, not a defect. The logic is heavily commented and the subtleties are load-bearing.)
[Author response: Author selected findings 1, 4, and 5 to fix during review and left 2 and 3 as
acknowledged/low-risk.]
Template Propagation
Shared Regions Modified
None. No
#region shared with <url>markers exist in any changed file.Extension Config Changes
None. The only
extensions/file changed isextensions/src/platform-scripture/src/types/platform-scripture.d.ts, a type-declaration file (not awebpack.config.ts/tsconfig.json/package.json/.eslintrc*), so no template propagation isneeded.
Positive Observations
@papi/changes: every public modification is additive or an appended optionalparameter; old
getScrRef/ two-argsetScrRefcall paths retain identical behavior.platformScripture.mapVerseRefBetweenProjectscommand is correctly declared as a type inplatform-scripture.d.tswhile registered by C# core — matching the establishedplatformScripture.invalidateCheckResultsprecedent — and the TS declaration and C# implementationagree on parameter order and types. Marked
@experimentalon both sides.persistence across reload, same-numbered-different-source, the
platform.verseRefecho no-clobber,cache hits, concurrent-request coalescing, the share-versification fast path, and transient-failure
fallback + retry; hook-side conversion, fallback, detach seeding (including mid-conversion re-seed),
and re-conversion on a source-only change.
VersificationConversionServiceTests.csasserts against a direct libpalaso conversion (verifyingwiring, not libpalaso's tables) and covers same-project, source-grounding, null-source, unresolvable
source/target,
HasMultiplebridge refs, and segment preservation — matching the best-effortcontract.
planConversion+conversionCacheKeyare shared bygetScrRefForProjectandgetScrRefForProjectSync, with an explicit comment that the sharedgate/key shape is what keeps the synchronous seed from missing the async-written cache.
VersificationConversionServicefollows the established command-handler-service pattern(
internal class, primary-constructorPapiClient,InitializeAsyncwired inProgram.cs), andc-sharp/Projects/is a defensible home for it.preserveValue: falseavoids a visible flash ofthe wrong verse — good attention to perceived UI stability. The scroll-group interaction pattern is
reinforced, not altered.
logger.warn,Console.WriteLine) are correctly not routed throughlocalization;
papi.d.tsappears regenerated rather than hand-edited.Interview Notes
(PT-4036 / PT-4037).
rest, leaving a clean tree.
an async
getScrRefForProjectre-seed in addition to the synchronousdisplayScrRefseed. Theauthor explained the mechanism well: use a synchronous cached value when available for an instant,
correct display; on first visit the cache is empty so it falls back to the raw ref, and the async
re-seed then corrects the now-independent ref if the resolved value differs. The author stated
plainly: "Claude designed it, and I reviewed it." They can describe the general mechanism but did
not independently surface the two finer points — the re-attach guard
(
scrollGroupIdLocalRef.current === undefined) that prevents a late async arrival from clobberingnew state, and the "permanent freeze" rationale (once detached, the independent ref is treated as
this project's own frame and never re-converted). These were supplied by the reviewer.
setScrollGroupId— AI-authored andauthor-reviewed. Recommend walking it end-to-end in the review meeting rather than treating it as
settled.
toolbar's BCV control is set by hand, the
sourceProjectIdstamped on the scroll-group update — andtherefore the frame every other follower converts from — is
currentProject?.id.currentProjectis resolved in
use-project-picker-data.hook.tsas the firstplatformScriptureEditor.reactwebview (that has a
projectId) encountered as rc-dock traverses the dock-layout tree(
getAllOpenWebViewDefinitions→dockLayout.getAllWebViewDefinitions()→dockLayout.find(..., Filter.AnyTab), then.find(predicate)takes the first match). Key properties:entirely — their versification never becomes the source.
floating), NOT the focused/active tab, NOT most-recently-used, NOT most-recently-opened. It shifts
if the user rearranges panels or closes that first editor. The only user-visible signal is the
toolbar's
currentProject.fullName (shortName)label.currentProjectisundefined, sosourceProjectId: undefinedissent and followers pass the reference through unconverted (unknown frame, deliberately not
assumed English).
currentProjectselection pre-dates this branch (the picker already used it), but the branchgives it new weight: it now determines cross-project verse conversion, not just the picker label.
acknowledged / low-risk (no code change).
In-Review Quality Check
Changes were made during the review (one production fix in the hook, three added tests). After
staging them, the following were run:
npm run typecheck— passed (exit 0, no errors), including after the inlined-guard change thatre-narrows
projectId.exhaustive-depserror (see Critical) and aparanext/require-disable-commenton a neededno-await-in-loopdisable in the new eviction test (added a justification comment;no-await-in-loopgenuinely fires because the sequential awaits are required for deterministicinsertion-ordered eviction).
use-scroll-group-scr-ref.hook.test.ts+scroll-group.service-host.test.ts: 24passed;
VersificationConversionServiceTests: 8 passed. All three added tests were confirmed asgenuine revert tests (they fail when the corresponding implementation line is neutralized).
paranext-corerepo,so
eslint .cannot run natively (it finds@typescript-eslintin both node_modules and aborts onambiguity). Linting during this review used
--resolve-plugins-relative-to .(the worktree's ownplugins, same versions as the parent). A clean CI checkout is not nested and is unaffected — which
is exactly why the pre-existing
exhaustive-depserror would surface at CI.Suggested Review Focus
useScrollGroupScrRef.setScrollGroupId— AI-authored and author-reviewed.Walk through the first-visit cache-miss path, the async re-seed, the re-attach guard
(
scrollGroupIdLocalRef.current === undefined), and the "never re-converted after detach" freezerationale to confirm the author can own it independently.
exhaustive-depsfix — the error was only visible locally viaa special eslint invocation due to worktree nesting; verify the clean-checkout CI lint passes.
Editors of different versifications open, the source frame stamped on the reference (and used to
convert for every other follower) is whichever editor comes first in dock-layout traversal order,
independent of which editor is focused; and it is
undefined(unconverted passthrough) when noScripture Editor is open. Now that this selection drives cross-project verse conversion — not just the
picker label it originally fed — decide whether "first scripture editor in layout order" is the right
source, or whether it should track the focused/active editor. (Full trace in Interview Notes.)
setScrRefSyncno-op guard semantics — the versification-blindcompareScrRefsgate plusthe
sourceProjectId/shouldSetVerseRefSettinginteractions (echo no-clobber, source-only changestill emits but does not rewrite
platform.verseRef). Confirm the intended matrix matches theimplementation.
null/undefined) is deliberatelyNOT assumed English and is passed through unconverted. Confirm this is the desired product behavior
for restored/external
platform.verseRefvalues whose frame is unknown.key so the next conversion is fresh, but an already-displayed reference is not re-converted until the
next navigation. Confirm this trade-off is acceptable.