diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 3a77a19f..cd1ac1d7 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -75,3 +75,30 @@ badges describe unresolved discussions; they do not represent per-thread unread receipts. Do not color them orange based on `open` status. Any future per-thread unread indicator must implement the acknowledgement lifecycle above using viewer-specific state rather than a local color toggle. + +## Changes since the last visit + +Treat updates as reading signposts. Use a small orange dot beside each +changed heading, leaving paragraphs, code, tables, and diagrams in their +original surfaces and spacing. An unheaded introduction +gets one dot at its first block. Do not tint whole sections or draw rails +down the document. +On phones, keep the dot's full hit area inside the document card. Give the +notice horizontal padding and put its actions below the summary. + +A compact notice reports the changed section count and offers History and +Dismiss. Reserve its space even when hidden so a live update never pushes +the document down. A heading that stays fully in view for three seconds +loses its attention cue. Scrolling past quickly does not count. Hover or keyboard +focus on the dot keeps it visible and shows who last edited that section, +with relative and exact times. Keyboard focus remains blue. Dismiss clears +all cues; preserve their footprint so nothing moves under the reader. +If a section receives another live edit, its dot returns for that new edit. +New and renamed sections receive dots too. Dismiss acknowledges the current +updates; later edits can bring attention back, including the rewrite notice. +After a rewrite notice is dismissed, ordinary edits get fresh section dots. +Presentation mode defers the display of changes until the show ends; retain +every queued section's last editor and any extensive-update notice. +Extensive changes get one Updated throughout notice and a history link +instead of marking every section. These cues are per viewer and disappear +on the next visit. diff --git a/engine/app/assets/stylesheets/coplan/application.css b/engine/app/assets/stylesheets/coplan/application.css index 9612bfff..7b4326b1 100644 --- a/engine/app/assets/stylesheets/coplan/application.css +++ b/engine/app/assets/stylesheets/coplan/application.css @@ -1732,55 +1732,130 @@ img.avatar { .mt-md { margin-top: var(--space-md); } .mb-md { margin-bottom: var(--space-md); } -/* "Changed since you last looked" — one-time, per-viewer section - highlights (changed_sections_controller). Quiet left accent + faint - tint; gone on the next visit. */ -/* A run of adjacent changed blocks is one band, not one box per block: - the gap between blocks in a run becomes padding inside the tint instead - of untinted margin showing through, so a changed section reads as a - single stretch rather than a stack of stripes. Scoped under - .markdown-rendered to outrank its per-element margins. */ -.markdown-rendered .section-changed { - box-shadow: inset 3px 0 0 var(--color-primary); - background: color-mix(in srgb, var(--color-primary) 5%, transparent); - border-radius: 0; - padding-left: var(--space-sm); - margin-left: calc(-1 * var(--space-sm)); - margin-bottom: 0; - padding-bottom: var(--space-md); +/* Changes since the last visit are small signposts beside section starts. + No bands, padding changes, or overrides of prose/code/table surfaces. */ +.markdown-rendered .section-updated { + position: relative; + scroll-margin-top: 9rem; } -/* Headings inside a run keep their breathing room, as padding within the - band rather than a gap through it. The run's own first heading is - .section-changed--start and keeps its margin — that space separates the - band from the unchanged content above it. */ -.markdown-rendered .section-changed:is(h1, h2, h3):not(.section-changed--start) { - margin-top: 0; - padding-top: var(--space-xl); +/* Give the small dot a comfortable target without changing heading layout. + The empty span keeps update metadata out of copied text and section slugs. */ +.section-update-marker { + position: absolute; + left: -23px; + top: calc(0.65em - 8px); + width: 22px; + height: 22px; + border-radius: 50%; + cursor: help; } -.markdown-rendered .section-changed--start { - border-top-right-radius: var(--radius); - padding-top: var(--space-sm); +.section-update-marker::before { + content: ""; + position: absolute; + left: calc(50% - 2.5px); + top: calc(50% - 2.5px); + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--color-warning); } -.markdown-rendered .section-changed--end { - border-bottom-right-radius: var(--radius); - padding-bottom: var(--space-sm); - margin-bottom: var(--space-md); +.section-update-marker::after { + content: attr(data-tooltip); + position: absolute; + left: 23px; + top: calc(100% + 2px); + z-index: 12; + width: max-content; + max-width: min(28rem, calc(100vw - 3rem)); + padding: var(--space-sm) var(--space-md); + border: 1px solid var(--color-border); + border-radius: var(--radius); + background: var(--color-surface); + color: var(--color-text); + box-shadow: var(--shadow); + font-size: var(--text-sm); + font-weight: 400; + line-height: 1.5; + letter-spacing: normal; + white-space: pre-line; + overflow-wrap: anywhere; + opacity: 0; + visibility: hidden; + pointer-events: none; +} + +.section-update-marker:is(:hover, :focus-visible)::after { + opacity: 1; + visibility: visible; +} + +.section-update-marker:is(:hover, :focus-visible) { + background: var(--color-interaction-hover-bg); +} + +.section-update-marker:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 4px; +} + +.section-update-marker { transition: opacity 180ms ease; } +.section-updated--viewed .section-update-marker { + opacity: 0; + visibility: hidden; + pointer-events: none; +} + +@media (prefers-reduced-motion: reduce) { + .section-update-marker { transition: none; } } .changed-sections-note { display: flex; + flex-wrap: wrap; align-items: center; - gap: var(--space-xs); - margin-bottom: var(--space-md); + gap: var(--space-xs) var(--space-md); + padding: var(--space-sm) 0; + margin-bottom: var(--space-lg); + border-bottom: 1px solid var(--color-border); + background: var(--color-surface); + color: var(--color-text-muted); + font-size: var(--text-sm); } +/* Keep the notice's footprint even before the first live update. Override + the global [hidden] display rule only for this reserved reading space. */ +.changed-sections-note[hidden] { display: flex !important; visibility: hidden; } +.changed-sections-note--dismissed, +[data-editing="true"] .changed-sections-note { visibility: hidden; } +.changed-sections-note__summary { flex: 1 1 16rem; } +.changed-sections-note__controls { display: flex; align-items: center; gap: var(--space-sm); } + +.changed-sections-note button, .changed-sections-note a { - color: inherit; - text-decoration: underline; - text-underline-offset: 2px; + border: 0; + border-radius: var(--radius); + padding: 4px 6px; + background: transparent; + color: var(--color-text-muted); + font: inherit; + white-space: nowrap; + cursor: pointer; + text-decoration: none; +} + +.changed-sections-note button:hover, +.changed-sections-note a:hover { + color: var(--color-primary); + background: var(--color-interaction-hover-bg); +} + +.changed-sections-note button:focus-visible, +.changed-sections-note a:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; } /* Rendered markdown */ @@ -7292,7 +7367,27 @@ img.avatar { } .plan-document { - padding: var(--space-md); + /* Leave room for the update dot's full hit area inside the card. */ + padding: var(--space-lg); + } + + .changed-sections-note { + flex-direction: column; + align-items: flex-start; + gap: var(--space-xs); + padding: var(--space-sm); + } + + .changed-sections-note__summary { + flex: none; + min-height: 1.5em; + line-height: 1.5; + } + + .changed-sections-note__controls :is(a, button) { + min-height: 36px; + display: inline-flex; + align-items: center; } .references-section, @@ -7340,6 +7435,11 @@ img.avatar { } } +/* The summary wraps on small phones; reserve the same room for live edits. */ +@media (max-width: 360px) { + .changed-sections-note__summary { min-height: 3em; } +} + /* ═══ Expanded view ═══════════════════════════════════════════════════════ The shared takeover surface (coplan/expander.js) behind an expanded Mermaid diagram and an expanded data table. The chrome — title bar, diff --git a/engine/app/controllers/coplan/plans_controller.rb b/engine/app/controllers/coplan/plans_controller.rb index b1eddea8..3325d267 100644 --- a/engine/app/controllers/coplan/plans_controller.rb +++ b/engine/app/controllers/coplan/plans_controller.rb @@ -738,8 +738,8 @@ def load_needs_attention end # What changed after the viewer's last visit (see Plans::ChangedSections): - # section keys to highlight, or a rewrite to mention. Nothing on a first - # visit — highlighting the whole document would say nothing. + # section keys to mark, or extensive updates to mention. Nothing on a + # first visit — there is no earlier read to compare. def changed_sections_since_last_visit seen_at = PlanViewer.where(plan: @plan, user: current_user).pick(:last_seen_at) return Plans::ChangedSections::NONE if seen_at.nil? @@ -747,13 +747,15 @@ def changed_sections_since_last_visit current = @plan.current_plan_version return Plans::ChangedSections::NONE if current.nil? || current.created_at <= seen_at - base = @plan.plan_versions.where(created_at: ..seen_at).order(revision: :desc).first + base = @plan.plan_versions.where(created_at: ..seen_at).reorder(revision: :desc).first return Plans::ChangedSections::NONE if base.nil? - Plans::ChangedSections.call( + result = Plans::ChangedSections.call( old_content: base.content_markdown, new_content: current.content_markdown ) + @section_updates = Plans::SectionUpdates.call(plan: @plan, keys: result.keys, since_revision: base.revision) + result end # Looking at a plan advances your last-seen mark (so the "changed since diff --git a/engine/app/helpers/coplan/plan_events_helper.rb b/engine/app/helpers/coplan/plan_events_helper.rb index 154e0cd1..75fa9dff 100644 --- a/engine/app/helpers/coplan/plan_events_helper.rb +++ b/engine/app/helpers/coplan/plan_events_helper.rb @@ -9,6 +9,11 @@ def history_actor_name(item) item.agent_name.present? ? "#{item.agent_name} (via #{name})" : name end + def section_update_details(version) + { by: history_actor_name(version), at: version.created_at.iso8601, revision: version.revision, + ago: "#{time_ago_in_words(version.created_at)} ago" } + end + # Render a one-line, human-readable summary of a PlanEvent for the # history feed. Each event type gets a tailored "X → Y" or "added X" / # "removed X" phrasing instead of a generic field/before/after dump, diff --git a/engine/app/javascript/controllers/coplan/changed_sections_controller.js b/engine/app/javascript/controllers/coplan/changed_sections_controller.js index ae2674bf..3dc3d5f3 100644 --- a/engine/app/javascript/controllers/coplan/changed_sections_controller.js +++ b/engine/app/javascript/controllers/coplan/changed_sections_controller.js @@ -1,79 +1,171 @@ import { Controller } from "@hotwired/stimulus" import { renderedBlocks } from "coplan/content_sections" -// One-time "changed since you last looked" highlights. The server passes -// the slug keys of sections whose content changed since this viewer's -// last visit (Plans::ChangedSections); we tint those sections — heading -// through to the next heading — and drop a small note above the content. -// The server advanced last_seen_at on this same request, so a reload -// renders clean: the highlight happens exactly once, only for you. -// -// When the server says the plan was rewritten there are no keys: banding -// the whole page tells you nothing, so the note stands alone and points at -// the history instead. -// -// Slugs are computed here with the same algorithm as the TOC -// (content_nav_controller#slugify) including duplicate -2/-3 suffixes, -// so both sides agree without coordinating on DOM ids. +// Quiet dots outside authored content, dismissed after a short reading pause. const TOP_KEY = "__top__" +const READ_DELAY = 3000 export default class extends Controller { - static values = { keys: Array, rewritten: Boolean, historyUrl: String } + static targets = ["note", "summary", "markerTemplate"] + static values = { keys: Array, rewritten: Boolean, viewed: Array, updates: Object, revision: Number } connect() { + this.disconnect() + this.sections = new Map() + this.visible = new Set() + this.held = new Map() + this.timers = new Map() const content = this.element.querySelector("#plan-content-body") - const rendered = content?.querySelector(".markdown-rendered") - if (!rendered) return - + if (!content || !this.hasNoteTarget || this.noteTarget.classList.contains("changed-sections-note--dismissed")) return + this.element.querySelectorAll(".section-update-marker").forEach(node => node.remove()) + this.element.querySelectorAll(".section-updated").forEach(node => { + node.classList.remove("section-updated", "section-updated--viewed") + }) if (this.rewrittenValue) { - this._insertNote(rendered, "Rewritten since you last looked.", true) + this.noteTarget.hidden = false + this.summaryTarget.textContent = "Updated throughout since your last visit." return } - if (this.keysValue.length === 0) return const keys = new Set(this.keysValue) const used = new Set() - let marking = keys.has(TOP_KEY) - - // Adjacent changed blocks are banded as one run rather than one box - // apiece — a section of six paragraphs should read as a single tinted - // stretch, not six stripes with gaps between them. - const runs = [] - let run = null - + let inIntroduction = true for (const node of renderedBlocks(content)) { if (/^H[1-3]$/.test(node.tagName)) { - // Every heading is slugged, changed or not: the `used` set carries - // the -2/-3 duplicate counter and has to stay in step with Ruby's. - marking = keys.has(this._slug(node.textContent, used)) - } - if (!marking) { - run = null - continue + inIntroduction = false + const key = this._slug(node.textContent, used) + if (keys.has(key)) this._mark(node, key) + } else if (inIntroduction && keys.has(TOP_KEY) && !this.sections.has(TOP_KEY)) { + this._mark(node, TOP_KEY) } - if (!run) { - run = [] - runs.push(run) + } + this.noteTarget.hidden = this.sections.size === 0 + this.summaryTarget.textContent = `${this.sections.size} section${this.sections.size === 1 ? "" : "s"} updated since your last visit.` + this.observer = new IntersectionObserver(entries => { + entries.forEach(entry => { + const node = entry.target + if (entry.isIntersecting && entry.intersectionRatio === 1) { + this.visible.add(node) + this._schedule(node) + } else { + this.visible.delete(node) + this._cancel(node) + } + }) + }, { rootMargin: "-72px 0px -15% 0px", threshold: 1 }) + this.sections.forEach(node => this.observer.observe(node)) + } + + disconnect() { + this.observer?.disconnect() + this.timers?.forEach(timer => clearTimeout(timer)) + } + + refresh(event) { + if (!this.element.querySelector("#plan-content-body")?.contains(event.target)) return + if (event.detail?.update) { + const updates = { ...this.updatesValue } + const incoming = key => event.detail.update.updates?.[key] || event.detail.update + // Acknowledging an earlier edit does not acknowledge new material. + // Compare revisions: separate edits can share the same timestamp. + const changed = event.detail.keys.filter(key => incoming(key).revision > (updates[key]?.revision || 0)) + if (event.detail.update.revision > this.revisionValue) { + this.revisionValue = event.detail.update.revision + if (changed.length || event.detail.update.rewritten) { + if (this.noteTarget.classList.contains("changed-sections-note--dismissed")) { + this.keysValue = [] + this.rewrittenValue = Boolean(event.detail.update.rewritten) + } + this.noteTarget.classList.remove("changed-sections-note--dismissed") + this.noteTarget.inert = false + } } - run.push(node) + this.viewedValue = this.viewedValue.filter(key => !changed.includes(key)) + this.keysValue = [...new Set([...this.keysValue, ...changed])] + if (event.detail.update.rewritten) this.rewrittenValue = true + event.detail.keys.forEach(key => { updates[key] = incoming(key) }) + this.updatesValue = updates } + this.connect() + } + + visibilityChanged() { + this.visible.forEach(node => { + this._cancel(node) + if (!document.hidden) this._schedule(node) + }) + } - for (const nodes of runs) { - for (const node of nodes) node.classList.add("section-changed") - nodes[0].classList.add("section-changed--start") - nodes[nodes.length - 1].classList.add("section-changed--end") + hold(event) { + const node = event.currentTarget.closest(".section-updated") + const reasons = this.held.get(node) || new Set() + reasons.add(event.type.startsWith("focus") ? "focus" : "hover") + this.held.set(node, reasons) + this._cancel(node) + } + + release(event) { + const node = event.currentTarget.closest(".section-updated") + const reasons = this.held.get(node) + reasons?.delete(event.type.startsWith("focus") ? "focus" : "hover") + if (!reasons?.size) this.held.delete(node) + if (this.visible.has(node)) this._schedule(node) + } + + dismiss() { + this.sections.forEach(node => this._view(node)) + this.noteTarget.classList.add("changed-sections-note--dismissed") + this.noteTarget.inert = true + this.disconnect() + } + + _mark(node, key) { + this.sections.set(key, node) + node.dataset.updatedSectionKey = key + node.classList.add("section-updated") + // Introductions and headings share the same hoverable, focusable dot. + const marker = this.markerTemplateTarget.content.firstElementChild.cloneNode(true) + const update = this.updatesValue[key] + let description = "Updated since your last visit. Open History for details." + if (update) { + const when = new Date(update.at).toLocaleString(undefined, { + month: "short", day: "numeric", year: "numeric", hour: "numeric", minute: "2-digit", timeZoneName: "short" + }) + description = `Updated by ${update.by}\n${update.ago} · ${when}` } + marker.dataset.tooltip = description + marker.setAttribute("aria-label", description) + node.appendChild(marker) + if (this.viewedValue.includes(key)) this._view(node) + } + + _schedule(node) { + if (document.hidden || this.held.has(node) || node.classList.contains("section-updated--viewed") || this.timers.has(node)) return + this.timers.set(node, setTimeout(() => { + this.timers.delete(node) + if (!document.hidden && this.visible.has(node) && !this.held.has(node) && !this.element.closest("[data-editing='true']")) this._view(node) + }, READ_DELAY)) + } + + _cancel(node) { + clearTimeout(this.timers.get(node)) + this.timers.delete(node) + } - if (runs.length > 0) this._insertNote(rendered, "Highlighted sections changed since you last looked.", false) + _view(node) { + this._cancel(node) + this.viewedValue = [...new Set([...this.viewedValue, node.dataset.updatedSectionKey])] + node.classList.add("section-updated--viewed") + const marker = node.querySelector(".section-update-marker") + if (marker) { + marker.tabIndex = -1 + marker.setAttribute("aria-hidden", "true") + } } _slug(text, used) { - let base = text - .toLowerCase() - .replace(/\s+/g, "-") - .replace(/[^a-z0-9-]/g, "") - .replace(/-{2,}/g, "-") - .replace(/^-|-$/g, "") + let base = text.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "") + .replace(/-{2,}/g, "-").replace(/^-|-$/g, "") if (base === "") base = "section" let slug = base let suffix = 2 @@ -81,24 +173,4 @@ export default class extends Controller { used.add(slug) return slug } - - _insertNote(rendered, text, withHistoryLink) { - // A Turbo snapshot restore re-runs connect() against cached HTML that - // already contains the note — don't stack a second one. - if (this.element.querySelector(".changed-sections-note")) return - - const note = document.createElement("p") - note.className = "changed-sections-note text-sm text-muted" - note.innerHTML = - ' ' - note.appendChild(document.createTextNode(text)) - - if (withHistoryLink && this.hasHistoryUrlValue) { - const link = document.createElement("a") - link.href = this.historyUrlValue - link.textContent = "See what changed" - note.appendChild(link) - } - rendered.parentNode.insertBefore(note, rendered) - } } diff --git a/engine/app/javascript/controllers/coplan/live_update_controller.js b/engine/app/javascript/controllers/coplan/live_update_controller.js index 245e96f1..93f93b12 100644 --- a/engine/app/javascript/controllers/coplan/live_update_controller.js +++ b/engine/app/javascript/controllers/coplan/live_update_controller.js @@ -54,9 +54,8 @@ export default class extends Controller { // If the local DOM is already at this revision (or newer), skip — this // tab is the one that issued the edit, no need to re-render. const currentRevision = parseInt(target.getAttribute("data-coplan--live-update-revision-value"), 10) || 0 - if (incomingRevision && currentRevision >= incomingRevision) return - let changedKeys = [] + let sectionUpdate = null try { // ChangedSections::Result serializes as {"keys": [...], "rewritten": bool}. // A full rewrite arrives with no keys, so the swap stays flash-free @@ -64,8 +63,20 @@ export default class extends Controller { // too, in case a not-yet-upgraded server is still broadcasting one. const parsed = JSON.parse(this.getAttribute("data-changed-sections") || "[]") changedKeys = Array.isArray(parsed) ? parsed : (Array.isArray(parsed?.keys) ? parsed.keys : []) + sectionUpdate = JSON.parse(this.getAttribute("data-section-update") || "null") } catch { /* malformed attribute — fall back to a flash-free swap */ } + if (incomingRevision && currentRevision >= incomingRevision) { + // A local save already installed the body. Keep marker attribution + // current without swapping it again or disturbing an active editor. + if (currentRevision === incomingRevision && sectionUpdate) { + target.dispatchEvent(new CustomEvent("coplan:section-update", { + bubbles: true, detail: { keys: sectionUpdate.keys || changedKeys, update: sectionUpdate } + })) + } + return + } + // `templateContent` is a DocumentFragment — it has no `innerHTML`. // Use replaceChildren(fragment) to swap the contents of target in one // shot. Stimulus controllers inside target will disconnect + reconnect. @@ -73,22 +84,27 @@ export default class extends Controller { if (target.querySelector(".deck--presenting")) { // Keep the active deck and its presenter connected. Apply the latest - // whole-body update as soon as the show ends. - target.__pendingDeckUpdate = { fragment: fragment.cloneNode(true), incomingRevision, changedKeys } + // body after the show, retaining every queued section's attribution. + const previous = target.__pendingDeckUpdate + const latest = previous?.incomingRevision > incomingRevision ? previous : + { fragment: fragment.cloneNode(true), incomingRevision } + const combined = mergeSectionUpdates(previous?.sectionUpdate, sectionUpdate) + const keys = [...new Set([...(previous?.changedKeys || []), ...changedKeys])] + target.__pendingDeckUpdate = { ...latest, changedKeys: combined?.rewritten ? [] : keys, sectionUpdate: combined } if (!target.__deckStopListener) { target.__deckStopListener = () => { const pending = target.__pendingDeckUpdate target.__pendingDeckUpdate = null if (!pending) return if (hasDirtyDrafts()) showStaleBanner(target, pending.incomingRevision) - else applyContent(target, pending.fragment, pending.incomingRevision, pending.changedKeys) + else applyContent(target, pending.fragment, pending.incomingRevision, pending.changedKeys, pending.sectionUpdate) } target.addEventListener("coplan:deck-stopped", target.__deckStopListener) } } else if (hasDirtyDrafts()) { showStaleBanner(target, incomingRevision) } else { - applyContent(target, fragment, incomingRevision, changedKeys) + applyContent(target, fragment, incomingRevision, changedKeys, sectionUpdate) } } @@ -96,14 +112,33 @@ export default class extends Controller { } } -function applyContent(target, fragment, incomingRevision, changedKeys) { +function mergeSectionUpdates(previous, incoming) { + if (!previous) return incoming + if (!incoming) return previous + const updates = {} + for (const update of [previous, incoming]) { + for (const key of update.keys || []) { + const candidate = update.updates?.[key] || update + if (!updates[key] || candidate.revision >= updates[key].revision) updates[key] = candidate + } + } + const latest = incoming.revision >= previous.revision ? incoming : previous + return { + ...latest, updates, keys: Object.keys(updates), + rewritten: Boolean(previous.rewritten || incoming.rewritten) + } +} + +function applyContent(target, fragment, incomingRevision, changedKeys, sectionUpdate) { const viewport = captureViewport(target) const oldSections = snapshotSections(target, changedKeys) const deckPositions = captureDeckPositions(target) target.replaceChildren(fragment) restoreDeckPositions(target, deckPositions) if (incomingRevision) target.setAttribute("data-coplan--live-update-revision-value", String(incomingRevision)) - target.dispatchEvent(new CustomEvent("coplan:content-updated", { bubbles: true })) + target.dispatchEvent(new CustomEvent("coplan:content-updated", { + bubbles: true, detail: { keys: sectionUpdate?.keys || changedKeys, update: sectionUpdate } + })) restoreViewport(target, viewport) requestAnimationFrame(() => restoreViewport(target, viewport)) clearStaleBanner() diff --git a/engine/app/services/coplan/broadcaster.rb b/engine/app/services/coplan/broadcaster.rb index 8ee3d2ff..99c4fa4f 100644 --- a/engine/app/services/coplan/broadcaster.rb +++ b/engine/app/services/coplan/broadcaster.rb @@ -72,6 +72,11 @@ def replace_plan_content(plan, changed_sections: nil) html = render(partial: "coplan/plans/content_body", locals: { plan: plan }) attrs = { "data-revision" => plan.current_revision } attrs["data-changed-sections"] = changed_sections.to_json if changed_sections.present? + if (version = plan.current_plan_version) + details = ApplicationController.helpers.section_update_details(version) + changes = section_update_changes(plan, changed_sections) + attrs["data-section-update"] = details.merge(keys: changes.keys, rewritten: changes.rewritten?).to_json + end custom_action_to( plan, action: "coplan-replace-if-clean", @@ -130,6 +135,15 @@ def replace_plan_references(plan) private + # Some write paths do not request a diff flash. Still update attribution + # and mark new sections while preserving the extensive-update notice. + def section_update_changes(plan, changes) + return changes if changes.respond_to?(:rewritten?) + + previous = plan.plan_versions.where("revision < ?", plan.current_revision).reorder(revision: :desc).first + Plans::ChangedSections.call(old_content: previous&.content_markdown, new_content: plan.current_content) + end + def render(partial:, locals:) CoPlan::ApplicationController.render(partial: partial, locals: locals) end diff --git a/engine/app/services/coplan/plans/changed_sections.rb b/engine/app/services/coplan/plans/changed_sections.rb index 7cc4c3ef..15294897 100644 --- a/engine/app/services/coplan/plans/changed_sections.rb +++ b/engine/app/services/coplan/plans/changed_sections.rb @@ -1,7 +1,7 @@ module CoPlan module Plans # Section-level diff between two markdown documents, used by the - # "changed since you last looked" one-time highlight on the plan page. + # "changed since you last looked" one-time signposts on the plan page. # # Sections are computed on the *rendered* document, not the raw # markdown: the content is run through the same Commonmarker pipeline @@ -19,11 +19,9 @@ module Plans # duplicate suffixes as the client. A slug the client can't match # just means that section quietly doesn't highlight — the safe failure. # - # Past a point the highlights stop being worth drawing: if you glanced - # at a plan while the agent was still drafting it, or the agent rewrote - # the thing, every section differs and the page turns into one big - # band. That case comes back as `rewritten?` with no keys — the page - # says so in a line of text instead of highlighting everything. + # If most of a long plan changed, section signposts tell the reader + # little. Return `rewritten?` with no keys so the page can offer one + # "Updated throughout" notice and a history link instead. class ChangedSections TOP_KEY = "__top__".freeze HEADING_TAGS = %w[h1 h2 h3].freeze @@ -32,9 +30,8 @@ class ChangedSections # swarm of one-line sections changing isn't a rewrite, and neither is # one long section getting edited. REWRITE_RATIO = 0.5 - # Below this, highlighting everything is only a few inches of tint — - # legible, and more useful than a sentence about it. The notice is - # for documents long enough that a full-page band reads as noise. + # Short documents still benefit from a handful of section signposts. + # Reserve the extensive-update notice for longer documents. REWRITE_MIN_SECTIONS = 4 Result = Struct.new(:keys, :rewritten, keyword_init: true) do diff --git a/engine/app/services/coplan/plans/section_updates.rb b/engine/app/services/coplan/plans/section_updates.rb new file mode 100644 index 00000000..ef749aae --- /dev/null +++ b/engine/app/services/coplan/plans/section_updates.rb @@ -0,0 +1,52 @@ +module CoPlan + module Plans + # Attribute each signposted section to the most recent version that + # actually changed it. A later edit elsewhere must not take credit. + class SectionUpdates + def self.call(plan:, keys:, since_revision:) + new(plan: plan, keys: keys, since_revision: since_revision).call + end + + def initialize(plan:, keys:, since_revision:) + @plan = plan + @keys = keys + @since_revision = since_revision + end + + def call + return {} if @keys.empty? + + pending = @keys.to_set + updates = {} + newer_version = nil + newer_sections = nil + versions = @plan.plan_versions.where(revision: @since_revision..@plan.current_revision) + .includes(:actor_user) + + # Walk backwards and stop as soon as every changed section has an + # author. Batching avoids loading an entire long version history. + loop do + batch = versions.reorder(revision: :desc).limit(20).to_a + break if batch.empty? + + batch.each do |version| + sections = ChangedSections.sections(version.content_markdown) + if newer_version + pending.to_a.each do |key| + next if sections.key?(key) && sections[key] == newer_sections[key] + + updates[key] = newer_version + pending.delete(key) + end + return updates if pending.empty? + end + newer_version = version + newer_sections = sections + end + versions = versions.where("revision < ?", batch.last.revision) + end + updates + end + end + end +end diff --git a/engine/app/views/coplan/agent_instructions/guides/editing.text.erb b/engine/app/views/coplan/agent_instructions/guides/editing.text.erb index cd284264..e566378c 100644 --- a/engine/app/views/coplan/agent_instructions/guides/editing.text.erb +++ b/engine/app/views/coplan/agent_instructions/guides/editing.text.erb @@ -17,6 +17,12 @@ This guide assumes that you have read the primer at <<%= @base %>/agent-instruct CoPlan compares your content with the current version and keeps comments anchored to text that you did not change. If your edit removes the text that a comment is anchored to, the thread becomes out of date. Address the comment before you delete its text. +Readers see small dots beside sections changed since their last visit. Dots clear after a short reading pause and return if that section receives another live edit. Hover or focus a dot to see who last updated that section and when. Readers can also open History or dismiss the markers. Extensive changes show one notice. Keep change explanations in `change_summary`, not in extra changelog sections. + +New or renamed sections in live edits receive dots. Dismiss acknowledges the current updates; later edits can show fresh attention. + +Presentation mode keeps the show stable. After it ends, readers see all queued updates. + ## Hand edits A person's hand edit has the highest authority in a plan. CoPlan merges agent edits that happen at the same time, but it does not merge over a hand edit. After a person edits a plan by hand, CoPlan refuses every agent write until that agent reads the plan again. Hand edits include edits in the web editor and checkbox selections. diff --git a/engine/app/views/coplan/plans/_changed_sections_note.html.erb b/engine/app/views/coplan/plans/_changed_sections_note.html.erb new file mode 100644 index 00000000..f0e3d2ae --- /dev/null +++ b/engine/app/views/coplan/plans/_changed_sections_note.html.erb @@ -0,0 +1,21 @@ +<%# Outside selectable / live-swapped content; server-rendered so connecting + Stimulus never pushes the document down. %> + + diff --git a/engine/app/views/coplan/plans/show.html.erb b/engine/app/views/coplan/plans/show.html.erb index f0ecd199..cd4a6ca5 100644 --- a/engine/app/views/coplan/plans/show.html.erb +++ b/engine/app/views/coplan/plans/show.html.erb @@ -55,9 +55,10 @@ <%= render partial: "coplan/plans/banner", locals: { plan: @plan } %> +<% update_details = (@section_updates || {}).transform_values { |version| section_update_details(version) } %>
<% if @plan.current_content.present? || params[:edit].present? && allowed_to?(@plan, :edit_content?) %> -
+