Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 35 additions & 2 deletions engine/app/assets/stylesheets/coplan/application.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand Down
14 changes: 7 additions & 7 deletions engine/app/controllers/coplan/plans_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,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

Expand Down Expand Up @@ -655,18 +655,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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 =
'<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></svg> ' +
"Highlighted sections changed since you last looked."
'<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></svg> '
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)
}
}
43 changes: 42 additions & 1 deletion engine/app/services/coplan/plans/changed_sections.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,56 @@ 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?(new_sections, changed)

Result.new(keys: changed, rewritten: false)
end

def self.rewritten?(new_sections, changed)
return false if new_sections.size < REWRITE_MIN_SECTIONS
return false unless changed.size > new_sections.size * REWRITE_RATIO

total = new_sections.sum { |_key, body| body.length }
return true if total.zero?

changed.sum { |key| new_sections[key].length } > total * REWRITE_RATIO

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Exclude unchanged bodies when headings are renamed

When a plan has at least four sections and more than half of their headings are renamed, changed contains the new slug keys, so this sum counts each section's entire body as changed even if those bodies are byte-for-byte identical. For example, renaming three headings in a four-section plan with substantial unchanged bodies satisfies both thresholds and tells the reader the plan was rewritten; calculate changed volume from the actual content differences or match renamed sections by body before classifying a rewrite.

Useful? React with 👍 / 👎.

end

def self.sections(markdown)
Expand Down
2 changes: 1 addition & 1 deletion engine/app/views/coplan/plans/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@

<div class="plan-document card">
<% if @plan.current_content.present? %>
<div class="plan-layout" data-controller="coplan--text-selection coplan--content-nav coplan--checkbox coplan--changed-sections coplan--reference-preview" data-coplan--text-selection-focus-thread-value="<%= params[:thread] %>" data-action="keydown.esc@document->coplan--text-selection#dismiss keydown.esc@document->coplan--reference-preview#dismiss click@document->coplan--reference-preview#dismissFromOutside scroll@window->coplan--reference-preview#reposition resize@window->coplan--reference-preview#reposition coplan:open-thread@document->coplan--text-selection#openThread" data-coplan--checkbox-revision-value="<%= @plan.current_revision %>" data-coplan--checkbox-toggle-url-value="<%= toggle_checkbox_plan_path(@plan) %>" data-coplan--changed-sections-keys-value="<%= @changed_section_keys.to_json %>">
<div class="plan-layout" data-controller="coplan--text-selection coplan--content-nav coplan--checkbox coplan--changed-sections coplan--reference-preview" data-coplan--text-selection-focus-thread-value="<%= params[:thread] %>" data-action="keydown.esc@document->coplan--text-selection#dismiss keydown.esc@document->coplan--reference-preview#dismiss click@document->coplan--reference-preview#dismissFromOutside scroll@window->coplan--reference-preview#reposition resize@window->coplan--reference-preview#reposition coplan:open-thread@document->coplan--text-selection#openThread" data-coplan--checkbox-revision-value="<%= @plan.current_revision %>" data-coplan--checkbox-toggle-url-value="<%= toggle_checkbox_plan_path(@plan) %>" data-coplan--changed-sections-keys-value="<%= @changed_sections.keys.to_json %>" data-coplan--changed-sections-rewritten-value="<%= @changed_sections.rewritten? %>" data-coplan--changed-sections-history-url-value="<%= history_plan_path(@plan) %>">
<nav class="content-nav" data-coplan--content-nav-target="sidebar" aria-label="Document outline">
<%# No "Contents" label — the outline speaks for itself, and the plan
title now lives in the sticky nav. Just the collapse control. %>
Expand Down
28 changes: 28 additions & 0 deletions spec/requests/changed_sections_highlight_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ def keys_attr(body)
body[/data-coplan--changed-sections-keys-value="([^"]*)"/, 1]
end

def rewritten_attr(body)
body[/data-coplan--changed-sections-rewritten-value="([^"]*)"/, 1]
end

it "sends no keys on a first-ever visit" do
sign_in_as(viewer)
get plan_path(plan)
Expand Down Expand Up @@ -45,4 +49,28 @@ def keys_attr(body)

expect(keys_attr(response.body)).to eq("[]")
end

# The agent filled in a plan you'd only glanced at: nothing to point at,
# so the page gets the rewrite flag and no keys.
it "flags a rewrite instead of sending keys when most of the plan changed" do
plan.current_plan_version.update_columns(created_at: 2.hours.ago)
CoPlan::PlanViewer.create!(plan: plan, user: viewer, last_seen_at: 1.hour.ago)

v2 = create(:plan_version, plan: plan, revision: 2, actor_id: author.id,
content_markdown: (1..5).map { |i| "## Part #{i}\n\nFreshly written body #{i}.\n" }.join("\n"))
plan.update_columns(current_plan_version_id: v2.id, current_revision: 2)

sign_in_as(viewer)
get plan_path(plan)

expect(keys_attr(response.body)).to eq("[]")
expect(rewritten_attr(response.body)).to eq("true")
end

it "does not flag a rewrite when there is nothing new at all" do
sign_in_as(viewer)
get plan_path(plan)

expect(rewritten_attr(response.body)).to eq("false")
end
end
63 changes: 62 additions & 1 deletion spec/services/plans/changed_sections_spec.rb
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
require "rails_helper"

RSpec.describe CoPlan::Plans::ChangedSections do
def call(old_content, new_content)
def result(old_content, new_content)
described_class.call(old_content: old_content, new_content: new_content)
end

def call(old_content, new_content)
result(old_content, new_content).keys
end

it "returns nothing when the content is unchanged" do
md = "# Title\n\nBody\n\n## Details\n\nMore\n"
expect(call(md, md)).to eq([])
Expand Down Expand Up @@ -106,4 +110,61 @@ def call(old_content, new_content)
new_md = "## AT&amp;T merger\n\nnew\n"
expect(call(old_md, new_md)).to eq([ "att-merger" ])
end

# Highlighting only helps when it points somewhere. Once most of the
# document is new — the plan you glanced at mid-draft, or a rewrite —
# every band lights up and says nothing, so this reports a rewrite and
# leaves the page alone.
describe "rewrites" do
def doc(*bodies)
bodies.each_with_index.map { |body, i| "## Section #{i + 1}\n\n#{body}\n" }.join("\n")
end

it "reports a rewrite instead of keys when most of a long plan changed" do
old_md = doc("one", "two", "three", "four", "five")
new_md = doc("wholly new", "also new", "new again", "and this", "five")

expect(result(old_md, new_md)).to have_attributes(rewritten?: true, keys: [])
end

it "still highlights when one long section of many changed" do
body = "prose " * 200
old_md = doc(body, "two", "three", "four", "five")
new_md = doc("#{body} plus an edit", "two", "three", "four", "five")

expect(result(old_md, new_md)).to have_attributes(rewritten?: false, keys: [ "section-1" ])
end

# Section count alone would call this a rewrite; by volume it's a few
# words against a wall of unchanged text.
it "still highlights when a swarm of one-line sections changed" do
long = "prose " * 200
old_md = doc(long, "a", "b", "c", "d")
new_md = doc(long, "A", "B", "C", "D")

expect(result(old_md, new_md)).to have_attributes(
rewritten?: false,
keys: [ "section-2", "section-3", "section-4", "section-5" ]
)
end

it "highlights a short plan in full rather than talking about it" do
old_md = doc("one", "two")
new_md = doc("new one", "new two")

expect(result(old_md, new_md)).to have_attributes(rewritten?: false, keys: [ "section-1", "section-2" ])
end

it "reports neither keys nor a rewrite when nothing changed" do
md = doc("one", "two", "three", "four", "five")

expect(result(md, md)).to have_attributes(rewritten?: false, keys: [])
end

# Every section is new against an empty baseline, which is the "you
# opened it while the agent was still drafting" case.
it "reports a rewrite when the plan grew from nothing into a long document" do
expect(result(nil, doc("one", "two", "three", "four"))).to have_attributes(rewritten?: true, keys: [])
end
end
end
Loading