Contribute rewrite Phase 3: tracked-change overlay in MarkdownPreview

Reintroduces the §8.10 inline tracked-change layer Phase 1 dropped when
Tiptap left, this time anchored to the rendered preview rather than a
writable editor. Each accepted change on the branch decorates the
preview DOM with a `<span class="tracked-insert">` at the proposed text
and a `<span class="tracked-delete">` strikethrough for the original;
hover surfaces the same ChangeTooltip DiffView uses (badge + prompt +
quote + reason), now extracted to its own file so both surfaces share
the affordance.

Design calls per the Phase 3 prompt's open list:
  • Contribute pane: default-on. The pane exists for editorial review
    of your own work; the overlay reinforces that.
  • Discuss pane: opt-in via a new "Show tracked changes" toolbar
    toggle on non-main branches. Clean prose stays the default.
  • DiffView's "Review changes" toolbar is untouched — DiffView dies in
    Phase 7 and overloading its toggle now creates surface to unwind.

Pre-fancy stance on the known overlay subtleties:
  • Drift — if `proposed` no longer appears verbatim (later manual
    edit touched it) we skip the decoration rather than mis-anchor.
  • Overlap — decorate in `acted_at` ascending order; later spans win
    on whatever text is still un-wrapped.
  • Mermaid — text nodes inside `.mermaid-block` are skipped; a tracked
    span that intersects diagram source drops silently. Flagged as a
    known limitation rather than worked around.
  • Code — `<pre>`/`<code>` parents skipped too; matching inside
    verbatim code produced noisy false positives in fixtures.

Backend tests: 125 passed. Helper verified via Vite preview sandbox
eval across eight cases (single-paragraph match, distinct-original
strike, no-match skip, mermaid skip, two-change overlap ordering,
declined/pending ignored, whitespace-normalized cross-newline match,
code-block skip), plus computed-style proof for the new
`.markdown-preview .tracked-insert` / `.tracked-delete` rules. The
end-to-end CM6 → accept → overlay flow against a live branch wasn't
exercised (backend not running this session); the simpler unit-level
verification looked clean, but a future session with the backend up
should drive that golden path before Phase 4 piles on top.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-05-25 10:44:09 -07:00
parent ee6e3491e7
commit 2a7c099a33
7 changed files with 407 additions and 95 deletions
+15
View File
@@ -540,6 +540,21 @@
background: rgba(99, 102, 241, 0.22);
}
/* Tracked-change overlay (Phase 3, §8.10) — preview-pane equivalents of
the .editor-content .tiptap rules above. The Contribute pane shows
these by default; the Discuss pane gates them behind a toolbar
toggle. Hovering surfaces a ChangeTooltip with the source message
and reason. */
.markdown-preview .tracked-delete {
background: #fee2e2; color: #991b1b; text-decoration: line-through;
border-radius: 2px; padding: 1px 2px; cursor: pointer;
margin-right: 2px;
}
.markdown-preview .tracked-insert {
background: #dcfce7; color: #166534;
border-radius: 2px; padding: 1px 2px; cursor: pointer;
}
/* Narrow-viewport banner — Phase 2 punts the chat-drawer collapse to
Phase 6, so the split layout asks for ≥1280px. */
.narrow-viewport-banner {
+71
View File
@@ -0,0 +1,71 @@
// ChangeTooltip.jsx — the §8.10 hover affordance for a tracked-change
// span. Extracted from DiffView in Phase 3 so the MarkdownPreview
// tracked-changes overlay can reuse it. Same shape: badge row,
// user-prompt + selection-quote, and the AI's reason.
import { MODEL_STYLES } from '../modelStyles'
function tooltipStyle(x, y) {
const vw = window.innerWidth, vh = window.innerHeight
const style = {}
if (x > vw * 0.55) style.right = vw - x + 10
else style.left = x + 14
if (y > vh * 0.55) style.bottom = vh - y + 10
else style.top = y + 14
return style
}
export function changeContext(change, messages) {
if (!change?.source_message_id || !messages) return {}
const idx = messages.findIndex(m => m.id === change.source_message_id)
if (idx < 0) return {}
const assistant = messages[idx]
const userMsg = [...messages].slice(0, idx).reverse().find(m => m.role === 'user')
return { assistant, userMsg }
}
export default function ChangeTooltip({ change, messages, position }) {
const { assistant, userMsg } = changeContext(change, messages)
const style = { ...tooltipStyle(position.x, position.y), position: 'fixed' }
const modelStyle = MODEL_STYLES[assistant?.model_id] || MODEL_STYLES.default
return (
<div className="diff-tooltip" style={style}>
<div className="diff-tooltip-header">
{change.kind === 'ai' ? (
<span className="diff-tooltip-badge" style={{ background: modelStyle.bg, color: modelStyle.color }}>
{modelStyle.label}
</span>
) : (
<span className="diff-tooltip-badge diff-tooltip-badge--manual">Manual edit</span>
)}
{change.was_edited_before_accept && (
<span className="diff-tooltip-badge diff-tooltip-badge--edited">Edited before accept</span>
)}
</div>
{userMsg && (
<div className="diff-tooltip-prompt">
{userMsg.quote && (
<div className="diff-tooltip-quote">
"{userMsg.quote.length > 120 ? userMsg.quote.slice(0, 120) + '…' : userMsg.quote}"
</div>
)}
<div className="diff-tooltip-prompt-text">
{userMsg.text.length > 240 ? userMsg.text.slice(0, 240) + '…' : userMsg.text}
</div>
</div>
)}
{change.reason && (
<div className="diff-tooltip-reason">
<span className="diff-tooltip-reason-label">Reason</span>
{change.reason}
</div>
)}
{!userMsg && change.kind === 'ai' && (
<div className="diff-tooltip-no-context">
No linked conversation message in this session.
</div>
)}
</div>
)
}
+1 -66
View File
@@ -7,72 +7,7 @@
// tooltip with the change's type/model/prompt/reason context.
import { useState, useCallback } from 'react'
import { MODEL_STYLES } from '../modelStyles'
function tooltipStyle(x, y) {
const vw = window.innerWidth, vh = window.innerHeight
const style = {}
if (x > vw * 0.55) style.right = vw - x + 10
else style.left = x + 14
if (y > vh * 0.55) style.bottom = vh - y + 10
else style.top = y + 14
return style
}
function changeContext(change, messages) {
if (!change?.source_message_id) return {}
const idx = messages.findIndex(m => m.id === change.source_message_id)
if (idx < 0) return {}
const assistant = messages[idx]
const userMsg = [...messages].slice(0, idx).reverse().find(m => m.role === 'user')
return { assistant, userMsg }
}
function ChangeTooltip({ change, messages, position }) {
const { assistant, userMsg } = changeContext(change, messages)
const style = { ...tooltipStyle(position.x, position.y), position: 'fixed' }
const modelStyle = MODEL_STYLES[assistant?.model_id] || MODEL_STYLES.default
return (
<div className="diff-tooltip" style={style}>
<div className="diff-tooltip-header">
{change.kind === 'ai' ? (
<span className="diff-tooltip-badge" style={{ background: modelStyle.bg, color: modelStyle.color }}>
{modelStyle.label}
</span>
) : (
<span className="diff-tooltip-badge diff-tooltip-badge--manual">Manual edit</span>
)}
{change.was_edited_before_accept && (
<span className="diff-tooltip-badge diff-tooltip-badge--edited">Edited before accept</span>
)}
</div>
{userMsg && (
<div className="diff-tooltip-prompt">
{userMsg.quote && (
<div className="diff-tooltip-quote">
"{userMsg.quote.length > 120 ? userMsg.quote.slice(0, 120) + '…' : userMsg.quote}"
</div>
)}
<div className="diff-tooltip-prompt-text">
{userMsg.text.length > 240 ? userMsg.text.slice(0, 240) + '…' : userMsg.text}
</div>
</div>
)}
{change.reason && (
<div className="diff-tooltip-reason">
<span className="diff-tooltip-reason-label">Reason</span>
{change.reason}
</div>
)}
{!userMsg && change.kind === 'ai' && (
<div className="diff-tooltip-no-context">
No linked conversation message in this session.
</div>
)}
</div>
)
}
import ChangeTooltip from './ChangeTooltip.jsx'
export default function DiffView({ html, changes, messages }) {
const [tooltip, setTooltip] = useState(null)
+56 -7
View File
@@ -19,8 +19,10 @@
// attribute, and the renderer takes (source) → SVG. A future
// authoring layer can intercept before mount without restructuring.
import { useEffect, useRef } from 'react'
import { useEffect, useRef, useState, useCallback } from 'react'
import { Marked } from 'marked'
import { decorateAcceptedChanges } from './trackedOverlay.js'
import ChangeTooltip from './ChangeTooltip.jsx'
// Module-level mermaid loader. Holds the Promise across consumers so
// the chunk fetches exactly once across the app lifetime.
@@ -71,6 +73,15 @@ export default function MarkdownPreview({
content,
onSelectionChange,
className,
// Phase 3 — tracked-change overlay (§8.10). When `showTrackedChanges`
// is true, accepted changes from the branch's changes list are
// decorated inline as <span class="tracked-insert"> / <span
// class="tracked-delete">. Hovering a decorated span surfaces a
// ChangeTooltip with the source message + reason. `messages` is
// optional; without it the tooltip falls back to badge + reason.
changes,
messages,
showTrackedChanges = false,
}) {
const hostRef = useRef(null)
// Per-block source memo — lets us skip mermaid re-renders for blocks
@@ -79,8 +90,12 @@ export default function MarkdownPreview({
// Monotonic counter the async mermaid pass uses to bail if a newer
// render has already replaced the DOM beneath it.
const renderTokenRef = useRef(0)
// Tracked-change hover tooltip state. Shape: { change, position }.
const [tooltip, setTooltip] = useState(null)
// Render markdown → HTML on every content change.
// Render markdown → HTML on every content change. The tracked-change
// overlay runs in the same effect so accepted-change spans appear
// synchronously with the body itself — no flash of un-decorated text.
useEffect(() => {
if (!hostRef.current) return
const html = previewMarked.parse(content || '')
@@ -88,8 +103,11 @@ export default function MarkdownPreview({
const token = ++renderTokenRef.current
// Reset memo so the new block set re-renders from scratch.
lastMermaidSourcesRef.current = []
if (showTrackedChanges && changes && changes.length > 0) {
decorateAcceptedChanges(hostRef.current, changes)
}
renderMermaidBlocks(hostRef.current, lastMermaidSourcesRef, token, renderTokenRef)
}, [content])
}, [content, showTrackedChanges, changes])
// Window-selection bridge for §8.12. Listen on document mouseup so
// releases outside the preview still clear the prior selection.
@@ -123,11 +141,42 @@ export default function MarkdownPreview({
return () => document.removeEventListener('mouseup', handleMouseUp)
}, [onSelectionChange])
// Hover handler for tracked-change spans (§8.10). The decorated spans
// carry data-change-id; we look the change row up out of `changes`
// and surface a ChangeTooltip anchored to the cursor.
const handleMouseMove = useCallback((e) => {
if (!showTrackedChanges || !changes) return
const span = e.target.closest?.('[data-change-id]')
if (!span) {
if (tooltip) setTooltip(null)
return
}
const id = span.getAttribute('data-change-id')
const change = changes.find(c => String(c.id) === String(id))
if (!change) return
setTooltip({ change, position: { x: e.clientX, y: e.clientY } })
}, [showTrackedChanges, changes, tooltip])
const handleMouseLeave = useCallback(() => {
setTooltip(null)
}, [])
return (
<div
ref={hostRef}
className={`markdown-preview${className ? ' ' + className : ''}`}
/>
<>
<div
ref={hostRef}
className={`markdown-preview${className ? ' ' + className : ''}`}
onMouseMove={showTrackedChanges ? handleMouseMove : undefined}
onMouseLeave={showTrackedChanges ? handleMouseLeave : undefined}
/>
{tooltip && (
<ChangeTooltip
change={tooltip.change}
messages={messages || []}
position={tooltip.position}
/>
)}
</>
)
}
+28
View File
@@ -94,6 +94,11 @@ export default function RFCView({ viewer }) {
const [selection, setSelection] = useState(null)
const [reviewMode, setReviewMode] = useState(false)
const [reviewHTML, setReviewHTML] = useState('')
// Phase 3 — Discuss-mode opt-in for the §8.10 tracked-change overlay
// in the single-pane preview. Contribute mode is default-on (the
// pane exists for editorial review of your own work); Discuss mode
// keeps clean prose by default and exposes a toggle.
const [discussShowChanges, setDiscussShowChanges] = useState(false)
// Mode: discuss vs contribute (§8.3). Always discuss on main.
const [mode, setMode] = useState('discuss')
@@ -142,6 +147,7 @@ export default function RFCView({ viewer }) {
setPendingDiscussChanges([])
setManualPending(null)
setReviewMode(false)
setDiscussShowChanges(false)
setSelection(null)
setMode('discuss')
@@ -649,6 +655,22 @@ export default function RFCView({ viewer }) {
</span>
</div>
)}
{inDiscuss && branchParam !== 'main'
&& changes.some(c => c.state === 'accepted') && (
<div className="editor-toolbar">
<button
type="button"
className={`btn-review-toggle${discussShowChanges ? ' active' : ''}`}
onClick={() => setDiscussShowChanges(v => !v)}
title="§8.10 — surface accepted tracked changes inline in the preview"
>
{discussShowChanges ? 'Hide tracked changes' : 'Show tracked changes'}
</button>
<span className="editor-toolbar-hint">
{changes.filter(c => c.state === 'accepted').length} accepted on this branch
</span>
</div>
)}
{reviewMode ? (
<DiffView html={reviewHTML} changes={changes} messages={messages} />
) : (
@@ -666,6 +688,9 @@ export default function RFCView({ viewer }) {
<MarkdownPreview
content={previewContent}
onSelectionChange={handleSelectionChange}
changes={changes}
messages={messages}
showTrackedChanges
/>
</div>
</div>
@@ -674,6 +699,9 @@ export default function RFCView({ viewer }) {
content={editorContent}
onSelectionChange={handleSelectionChange}
className="markdown-preview-solo"
changes={changes}
messages={messages}
showTrackedChanges={inDiscuss && discussShowChanges}
/>
)}
<SelectionTooltip
+210
View File
@@ -0,0 +1,210 @@
// trackedOverlay.js — Phase 3 of the Contribute rewrite. Decorates the
// rendered MarkdownPreview DOM with `<span class="tracked-insert">` and
// `<span class="tracked-delete">` markup for each accepted change on
// the branch, restoring the §8.10 inline tracked-change layer that
// Phase 1 dropped when Tiptap left.
//
// The pipeline is DOM-side (post-marked.parse) rather than markdown-side
// so the overlay sees the exact text the reader sees — header /
// emphasis / inline code shape doesn't matter for matching, and we
// don't try to re-anchor proposed text that crosses inline tags.
// Single-text-node match is the pre-fancy stance; cross-tag spans skip
// cleanly per the Phase 3 instructions ("if proposed doesn't match,
// skip the decoration rather than mis-anchor").
//
// Subtleties handled:
// • acted_at ordering — later spans win on overlap because we always
// search for un-wrapped occurrences; an earlier wrap removes that
// substring from the search space, so a later change that hits the
// same passage just lands its mark on whatever is left.
// • mermaid intersections — text nodes inside `.mermaid-block` are
// skipped before matching, so a tracked span whose `proposed`
// only matches inside diagram source gets dropped silently.
// • drift — if the proposed text was further edited since acceptance
// it won't match verbatim; we skip rather than mis-anchor.
//
// Out of scope for Phase 3 (per the prompt): retro-fitting DiffView
// (which uses dangerouslySetInnerHTML — the per-session injection it
// expects is dying in Phase 7); rendering changes that have been
// declined or are still pending; cursor-aware "what's new since last
// visit" filtering.
const DECORATABLE_PARENT_SKIP = new Set([
'SCRIPT',
'STYLE',
'CODE',
'PRE',
])
function isInsideMermaid(node) {
let p = node.parentNode
while (p && p.nodeType === Node.ELEMENT_NODE) {
if (p.classList?.contains('mermaid-block')) return true
p = p.parentNode
}
return false
}
function isInsideTrackedSpan(node) {
let p = node.parentNode
while (p && p.nodeType === Node.ELEMENT_NODE) {
if (p.classList?.contains('tracked-insert')) return true
if (p.classList?.contains('tracked-delete')) return true
p = p.parentNode
}
return false
}
// Yield every text node inside `root` whose nearest non-text parent is
// neither inside a mermaid block nor inside an already-applied tracked
// span. Code/pre nodes are excluded — accepted-change text rarely
// targets verbatim code blocks and matching inside them produces noisy
// false positives.
function* eligibleTextNodes(root) {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
if (!node.nodeValue) return NodeFilter.FILTER_REJECT
const parent = node.parentElement
if (!parent) return NodeFilter.FILTER_REJECT
if (DECORATABLE_PARENT_SKIP.has(parent.tagName)) return NodeFilter.FILTER_REJECT
if (isInsideMermaid(node)) return NodeFilter.FILTER_REJECT
if (isInsideTrackedSpan(node)) return NodeFilter.FILTER_REJECT
return NodeFilter.FILTER_ACCEPT
},
})
let n = walker.nextNode()
while (n) {
yield n
n = walker.nextNode()
}
}
// Collapse the kind of whitespace differences marked produces (newlines
// become spaces, indentation folds) so a proposed string authored
// against raw markdown can still match against rendered text.
function normalizeForMatch(s) {
return String(s || '').replace(/\s+/g, ' ').trim()
}
// Find the first text node containing `needle` (after whitespace
// normalization) and return the (node, offsetInNodeValue,
// matchedLength) for splitting. Returns null when no eligible node
// contains the needle.
function findFirstMatch(root, needle) {
const target = normalizeForMatch(needle)
if (!target) return null
for (const node of eligibleTextNodes(root)) {
const haystack = node.nodeValue
const normalized = haystack.replace(/\s+/g, ' ')
const idx = normalized.indexOf(target)
if (idx < 0) continue
// Translate the normalized index back to the raw nodeValue offset.
// We walk the raw string counting non-whitespace characters until
// we hit the normalized index, then count out the matched length.
const start = mapNormalizedOffsetToRaw(haystack, idx)
const end = mapNormalizedOffsetToRaw(haystack, idx + target.length)
if (start == null || end == null || end <= start) continue
return { node, start, end }
}
return null
}
function mapNormalizedOffsetToRaw(raw, normalizedOffset) {
// Re-derive the position in `raw` that corresponds to position
// `normalizedOffset` in `raw.replace(/\s+/g, ' ')`. We walk raw,
// tracking how many normalized characters we've emitted so far.
let emitted = 0
let i = 0
let inWhitespaceRun = false
while (i <= raw.length) {
if (emitted === normalizedOffset) return i
if (i === raw.length) break
const c = raw[i]
if (/\s/.test(c)) {
if (!inWhitespaceRun) {
emitted += 1 // collapsed whitespace counts as one space
inWhitespaceRun = true
}
} else {
emitted += 1
inWhitespaceRun = false
}
i += 1
}
return emitted === normalizedOffset ? raw.length : null
}
function makeSpan(className, text, change) {
const span = document.createElement('span')
span.className = className
span.setAttribute('data-change-id', String(change.id))
if (change.source_message_id != null) {
span.setAttribute('data-source-message-id', String(change.source_message_id))
}
span.textContent = text
return span
}
// Replace the matched range inside `node` with: optional tracked-delete
// span (rendering `original` struck-through), followed by tracked-insert
// span wrapping the matched proposed text.
function decorateMatch(match, change) {
const { node, start, end } = match
const before = node.nodeValue.slice(0, start)
const matched = node.nodeValue.slice(start, end)
const after = node.nodeValue.slice(end)
const parent = node.parentNode
if (!parent) return
const insertSpan = makeSpan('tracked-insert', matched, change)
let deleteSpan = null
const originalText = (change.original || '').trim()
// Skip the strikethrough when original is empty (pure insertion) or
// when it would duplicate the inserted text exactly — common for
// light AI edits where the proposal essentially repeats the source.
if (originalText && normalizeForMatch(originalText) !== normalizeForMatch(matched)) {
deleteSpan = makeSpan('tracked-delete', change.original, change)
}
const beforeNode = before ? document.createTextNode(before) : null
const afterNode = after ? document.createTextNode(after) : null
// Build the replacement sequence in order.
const frag = document.createDocumentFragment()
if (beforeNode) frag.appendChild(beforeNode)
if (deleteSpan) frag.appendChild(deleteSpan)
frag.appendChild(insertSpan)
if (afterNode) frag.appendChild(afterNode)
parent.replaceChild(frag, node)
}
// Sort accepted changes by acted_at ascending so later spans win on
// overlap. Falls back to id ordering when acted_at is null/equal.
function orderForDecoration(changes) {
return changes
.filter(c => c?.state === 'accepted' && c.proposed)
.slice()
.sort((a, b) => {
const aa = a.acted_at || ''
const bb = b.acted_at || ''
if (aa === bb) return (a.id || 0) - (b.id || 0)
return aa.localeCompare(bb)
})
}
export function decorateAcceptedChanges(root, changes) {
if (!root || !changes || changes.length === 0) return
const ordered = orderForDecoration(changes)
for (const change of ordered) {
const match = findFirstMatch(root, change.proposed)
if (!match) continue
try {
decorateMatch(match, change)
} catch {
// Tolerate per-change failures so one bad span doesn't kill the
// overlay for everything that came after it.
}
}
}