diff --git a/engine/app/assets/stylesheets/coplan/application.css b/engine/app/assets/stylesheets/coplan/application.css index 5c0a5f0..32cf2c0 100644 --- a/engine/app/assets/stylesheets/coplan/application.css +++ b/engine/app/assets/stylesheets/coplan/application.css @@ -1577,12 +1577,39 @@ img.avatar { /* "Changed since you last looked" — one-time, per-viewer section highlights (changed_sections_controller). Quiet left accent + faint tint; gone on the next visit. */ -.section-changed { +/* 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 var(--radius) var(--radius) 0; + border-radius: 0; padding-left: var(--space-sm); margin-left: calc(-1 * var(--space-sm)); + margin-bottom: 0; + padding-bottom: var(--space-md); +} + +/* 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); +} + +.markdown-rendered .section-changed--start { + border-top-right-radius: var(--radius); + padding-top: var(--space-sm); +} + +.markdown-rendered .section-changed--end { + border-bottom-right-radius: var(--radius); + padding-bottom: var(--space-sm); + margin-bottom: var(--space-md); } .changed-sections-note { @@ -1592,6 +1619,12 @@ img.avatar { margin-bottom: var(--space-md); } +.changed-sections-note a { + color: inherit; + text-decoration: underline; + text-underline-offset: 2px; +} + /* Rendered markdown */ .markdown-rendered h1, .markdown-rendered h2, diff --git a/engine/app/controllers/coplan/plans_controller.rb b/engine/app/controllers/coplan/plans_controller.rb index ac24925..ee7aed5 100644 --- a/engine/app/controllers/coplan/plans_controller.rb +++ b/engine/app/controllers/coplan/plans_controller.rb @@ -161,7 +161,7 @@ def show # Order matters: compute the one-time "changed since you last looked" # highlights against the old last_seen_at, then advance it — so the # next visit renders clean. - @changed_section_keys = changed_sections_since_last_visit + @changed_sections = changed_sections_since_last_visit record_visit unless prefetch_request? end @@ -682,18 +682,18 @@ def load_needs_attention needs_attention end - # Section keys (see Plans::ChangedSections) for content that changed - # after the viewer's last visit. Empty on a first visit — highlighting - # the whole document would say nothing. + # 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. def changed_sections_since_last_visit seen_at = PlanViewer.where(plan: @plan, user: current_user).pick(:last_seen_at) - return [] if seen_at.nil? + return Plans::ChangedSections::NONE if seen_at.nil? current = @plan.current_plan_version - return [] if current.nil? || current.created_at <= seen_at + 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 - return [] if base.nil? + return Plans::ChangedSections::NONE if base.nil? Plans::ChangedSections.call( old_content: base.content_markdown, diff --git a/engine/app/javascript/controllers/coplan/changed_sections_controller.js b/engine/app/javascript/controllers/coplan/changed_sections_controller.js index 3381827..b26c8dc 100644 --- a/engine/app/javascript/controllers/coplan/changed_sections_controller.js +++ b/engine/app/javascript/controllers/coplan/changed_sections_controller.js @@ -7,35 +7,62 @@ import { Controller } from "@hotwired/stimulus" // 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. const TOP_KEY = "__top__" export default class extends Controller { - static values = { keys: Array } + static values = { keys: Array, rewritten: Boolean, historyUrl: String } connect() { - if (this.keysValue.length === 0) return const rendered = this.element.querySelector(".markdown-rendered") if (!rendered) return + if (this.rewrittenValue) { + this._insertNote(rendered, "Rewritten since you last looked.", true) + return + } + if (this.keysValue.length === 0) return + const keys = new Set(this.keysValue) const used = new Set() let marking = keys.has(TOP_KEY) - let markedAny = marking + + // 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 for (const node of Array.from(rendered.children)) { 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)) - markedAny = markedAny || marking } - if (marking) node.classList.add("section-changed") + if (!marking) { + run = null + continue + } + if (!run) { + run = [] + runs.push(run) + } + run.push(node) } - // A Turbo snapshot restore re-runs connect() against cached HTML that - // already contains the note — don't stack a second one. - if (markedAny && !this.element.querySelector(".changed-sections-note")) this._insertNote(rendered) + 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") + } + + if (runs.length > 0) this._insertNote(rendered, "Highlighted sections changed since you last looked.", false) } _slug(text, used) { @@ -53,12 +80,23 @@ export default class extends Controller { return slug } - _insertNote(rendered) { + _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 = - ' ' + - "Highlighted sections changed since you last looked." + ' ' + 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/voice_controller.js b/engine/app/javascript/controllers/coplan/voice_controller.js index 9cef8ad..db27c5e 100644 --- a/engine/app/javascript/controllers/coplan/voice_controller.js +++ b/engine/app/javascript/controllers/coplan/voice_controller.js @@ -81,9 +81,16 @@ export default class extends Controller { this.listening = false this._watchAgentPill() + + // The markup ships with the page; the control only works from here on. + // A key held — or the mic clicked — before this point has nothing + // listening for it, which is exactly what made the system specs flake + // under CI load. They wait for this attribute. + this.element.dataset.voiceReady = "true" } disconnect() { + delete this.element.dataset.voiceReady // A capture can be mid-flight — _startRecording awaiting the // microphone, the hold timer still deciding. Mark the take dead // first, so a promise that resumes after this teardown bails out diff --git a/engine/app/services/coplan/plans/changed_sections.rb b/engine/app/services/coplan/plans/changed_sections.rb index e691622..7cc4c3e 100644 --- a/engine/app/services/coplan/plans/changed_sections.rb +++ b/engine/app/services/coplan/plans/changed_sections.rb @@ -18,15 +18,79 @@ module Plans # ignored. Keys are slugified heading texts with the same `-2`, `-3` # 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. class ChangedSections TOP_KEY = "__top__".freeze HEADING_TAGS = %w[h1 h2 h3].freeze + # "Most of it changed" — measured against both the section count and + # the volume of text, since either alone misreads a common shape: a + # 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. + REWRITE_MIN_SECTIONS = 4 + + Result = Struct.new(:keys, :rewritten, keyword_init: true) do + def rewritten? + rewritten + end + end + + NONE = Result.new(keys: [].freeze, rewritten: false).freeze def self.call(old_content:, new_content:) old_sections = sections(old_content) - sections(new_content).filter_map do |key, body| + # An empty lead-in isn't a section; counting it would skew the + # rewrite ratio on every document that opens with a heading. + new_sections = sections(new_content).reject { |key, body| key == TOP_KEY && body.empty? } + + changed = new_sections.filter_map do |key, body| key if !old_sections.key?(key) || old_sections[key] != body end + return NONE if changed.empty? + return Result.new(keys: [], rewritten: true) if rewritten?(old_sections, new_sections, changed) + + Result.new(keys: changed, rewritten: false) + end + + def self.rewritten?(old_sections, new_sections, changed) + return false if new_sections.size < REWRITE_MIN_SECTIONS + + fresh = written_from_scratch(old_sections, new_sections, changed) + return false unless fresh.size > new_sections.size * REWRITE_RATIO + + total = new_sections.sum { |_key, body| body.length } + return true if total.zero? + + fresh.sum { |key| new_sections[key].length } > total * REWRITE_RATIO + end + + # Changed sections minus the ones that only moved. A renamed heading + # files an untouched body under a new key, which the key-level diff + # can't tell from newly written text — so three renames in a + # four-section plan would claim a rewrite the reader never got. Any + # body that already existed somewhere in the old document is carried + # over, not written; it still highlights (the heading did change), + # it just doesn't count toward "most of this is new". Matching is by + # exact body, one old section per new one, so duplicated text can't + # discount two sections at once. + def self.written_from_scratch(old_sections, new_sections, changed) + carried_over = old_sections.values.reject(&:empty?).tally + + changed.reject do |key| + body = new_sections[key] + next false unless carried_over.fetch(body, 0).positive? + + carried_over[body] -= 1 + true + end end def self.sections(markdown) diff --git a/engine/app/views/coplan/plans/show.html.erb b/engine/app/views/coplan/plans/show.html.erb index b0f0551..31b0714 100644 --- a/engine/app/views/coplan/plans/show.html.erb +++ b/engine/app/views/coplan/plans/show.html.erb @@ -50,7 +50,7 @@