Skip to content
Closed
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
17 changes: 15 additions & 2 deletions engine/app/controllers/coplan/plans_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -132,12 +132,23 @@ def toggle_checkbox
return
end

checkbox_pattern = /\A\s*[*+-]\s+\[[ xX]\]\s/
checkbox_pattern = MarkdownHelper::TASK_LINE_PATTERN
unless old_text.match?(checkbox_pattern) && new_text.match?(checkbox_pattern)
render json: { error: "old_text and new_text must be task list items" }, status: :unprocessable_content
return
end

# Optional source line number scoping the replacement to a single-line
# box, so identical task lines elsewhere in the document can't collide.
line = nil
if params[:line].present?
line = Integer(params[:line], exception: false)
if line.nil? || line < 1
render json: { error: "line must be a positive integer" }, status: :unprocessable_content
return
end
end

ActiveRecord::Base.transaction do
@plan.lock!
@plan.reload
Expand All @@ -148,9 +159,11 @@ def toggle_checkbox
end

current_content = @plan.current_content || ""
operation = { "op" => "replace_exact", "old_text" => old_text, "new_text" => new_text }
operation["lines"] = line if line
result = Plans::ApplyOperations.call(
content: current_content,
operations: [{ "op" => "replace_exact", "old_text" => old_text, "new_text" => new_text }]
operations: [operation]
)

new_revision = @plan.current_revision + 1
Expand Down
59 changes: 36 additions & 23 deletions engine/app/helpers/coplan/markdown_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,13 @@ module MarkdownHelper
details summary
].freeze

ALLOWED_ATTRIBUTES = %w[id class href src alt title type checked disabled data-line-text data-action data-coplan--checkbox-target data-mention-username].freeze
ALLOWED_ATTRIBUTES = %w[id class href src alt title type checked disabled data-line data-line-text data-action data-coplan--checkbox-target data-mention-username data-sourcepos].freeze

# A source line the toggle endpoint will accept as a task item. Must stay
# in sync with what Commonmarker's tasklist extension can toggle: a
# checkbox is only wired up when its source line matches, so the client
# never offers a toggle the server would reject or mis-target.
TASK_LINE_PATTERN = /\A\s*[*+-]\s+\[[ xX]\]\s/

# Matches `[@username](mention:username)` where the bracket text and link
# target encode the same username. Username allows letters, digits, dots,
Expand All @@ -23,7 +29,11 @@ module MarkdownHelper
MENTION_PATTERN = /\[@([\w.-]+)\]\(mention:\1\)/

def render_markdown(content, interactive: true)
html = Commonmarker.to_html(content.to_s.encode("UTF-8"), options: { render: { unsafe: true } }, plugins: { syntax_highlighter: nil })
render_options = { unsafe: true }
# Sourcepos is only needed to wire checkboxes to their source lines;
# make_checkboxes_interactive strips it from the final output.
render_options[:sourcepos] = true if interactive
html = Commonmarker.to_html(content.to_s.encode("UTF-8"), options: { render: render_options }, plugins: { syntax_highlighter: nil })
with_chips = transform_mention_anchors(html)
sanitized = sanitize(with_chips, tags: ALLOWED_TAGS, attributes: ALLOWED_ATTRIBUTES)
result = interactive ? make_checkboxes_interactive(sanitized, content) : sanitized
Expand Down Expand Up @@ -70,24 +80,31 @@ def render_line_view(content)

private

# Wires rendered task checkboxes to their source lines via Commonmarker's
# sourcepos metadata, so the parser that decides what renders as a
# checkbox is also the authority on which line it came from. A checkbox
# only becomes interactive when its own source line matches
# TASK_LINE_PATTERN — constructs Commonmarker renders but the toggle
# endpoint won't accept (ordered-list or blockquoted tasks) stay disabled
# rather than being paired with some other line's text.
def make_checkboxes_interactive(html, content)
doc = Nokogiri::HTML::DocumentFragment.parse(html)
checkboxes = doc.css('input[type="checkbox"]')
return html if checkboxes.empty?
source_lines = content.to_s.each_line.map(&:rstrip)

task_lines = extract_task_lines(content)
doc.css('input[type="checkbox"]').each do |cb|
li = cb.ancestors("li").first
line_number = sourcepos_start_line(li)
next unless line_number

checkboxes.each_with_index do |cb, i|
line_text = task_lines[i]
next unless line_text
line_text = source_lines[line_number - 1]
next unless line_text&.match?(TASK_LINE_PATTERN)

cb.remove_attribute("disabled")
cb["data-action"] = "coplan--checkbox#toggle"
cb["data-coplan--checkbox-target"] = "checkbox"
cb["data-line-text"] = line_text
cb["data-line"] = line_number.to_s

li = cb.parent
next unless li&.name == "li"
li.add_class("task-list-item")

# Wrap li contents in a <label> so the whole text is clickable
Expand All @@ -99,22 +116,18 @@ def make_checkboxes_interactive(html, content)
ul.add_class("task-list") if ul&.name == "ul"
end

doc.css("[data-sourcepos]").each { |el| el.remove_attribute("data-sourcepos") }
doc.to_html
end

def extract_task_lines(content)
lines = []
in_fence = false
content.to_s.each_line do |line|
stripped = line.rstrip
if stripped.match?(/\A(`{3,}|~{3,})/)
in_fence = !in_fence
next
end
next if in_fence
lines << stripped if stripped.match?(/^\s*[*+-]\s+\[[ xX]\]\s/)
end
lines
# Extracts the 1-based start line from an li's data-sourcepos
# ("3:1-4:0" -> 3).
def sourcepos_start_line(li)
raw = li&.[]("data-sourcepos")
return nil if raw.nil?

line = raw.split(":").first.to_i
line >= 1 ? line : nil
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,17 @@ export default class extends Controller {
? lineText.replace(/([*+-]\s+)\[[ ]\]/, "$1[x]")
: lineText.replace(/([*+-]\s+)\[[xX]\]/, "$1[ ]")

// Source line number disambiguates duplicate task-line text server-side.
const line = parseInt(checkbox.dataset.line, 10)

// Optimistic UI: update immediately
checkbox.dataset.lineText = newText

this.inflight = true
this.#sendToggle({ checkbox, oldText, newText, nowChecked, retried: false })
this.#sendToggle({ checkbox, oldText, newText, line, nowChecked, retried: false })
}

#sendToggle({ checkbox, oldText, newText, nowChecked, retried }) {
#sendToggle({ checkbox, oldText, newText, line, nowChecked, retried }) {
const token = document.querySelector('meta[name="csrf-token"]')?.content

fetch(this.toggleUrlValue, {
Expand All @@ -36,7 +39,8 @@ export default class extends Controller {
body: JSON.stringify({
old_text: oldText,
new_text: newText,
base_revision: this.revisionValue
base_revision: this.revisionValue,
...(Number.isInteger(line) && line >= 1 ? { line } : {})
})
}).then(response => {
if (response.ok) {
Expand All @@ -50,7 +54,7 @@ export default class extends Controller {
if (data.current_revision) {
this.revisionValue = data.current_revision
}
this.#sendToggle({ checkbox, oldText, newText, nowChecked, retried: true })
this.#sendToggle({ checkbox, oldText, newText, line, nowChecked, retried: true })
})
} else {
this.#revert(checkbox, oldText, nowChecked)
Expand Down
2 changes: 2 additions & 0 deletions engine/app/services/coplan/plans/commit_session.rb
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ def call
rebased_ops = []
@session.operations_json.each do |op_data|
op_data = op_data.transform_keys(&:to_s)
# "lines" is intentionally absent: this fallback re-resolves against
# *current* content, where a base-revision line box would be wrong.
semantic_keys = %w[op old_text new_text heading content needle occurrence replace_all count new_content include_heading]
semantic_op = op_data.slice(*semantic_keys)

Expand Down
56 changes: 56 additions & 0 deletions engine/app/services/coplan/plans/position_resolver.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,25 @@ def resolve_replace_exact
occurrence = (@op["occurrence"] || 1).to_i
raise OperationError, "replace_exact: occurrence must be >= 1, got #{occurrence}" if occurrence < 1

line_box = parse_line_box

ranges = find_all_occurrences(old_text)

if ranges.empty?
raise OperationError, "replace_exact found 0 occurrences of the specified text"
end

if line_box
box = char_range_for_lines(*line_box)
total = ranges.length
# Only occurrences fully contained in the box survive; the
# occurrence/replace_all/count rules below then index within it.
ranges = ranges.select { |s, e| s >= box[0] && e <= box[1] }
if ranges.empty?
raise OperationError, "replace_exact found #{total} occurrence(s) of the text, but none within lines #{line_box[0]}..#{line_box[1]}"
end
end

if replace_all
Resolution.new(op: "replace_exact", ranges: ranges)
else
Expand Down Expand Up @@ -108,6 +121,49 @@ def resolve_delete_paragraph_containing
Resolution.new(op: "delete_paragraph_containing", ranges: ranges)
end

# Optional "lines" qualifier: a 1-based inclusive line range ("boxed
# context") that scopes replace_exact matching. Accepts an Integer
# (single-line box) or a two-element [start, end] pair. Multi-line
# old_text is fine as long as the match fits inside the box.
def parse_line_box
raw = @op["lines"]
return nil if raw.nil?

bounds = raw.is_a?(Array) ? raw : [raw, raw]
unless bounds.length == 2 && bounds.all?(Integer)
raise OperationError, "replace_exact: 'lines' must be an integer line number or a [start, end] pair of integers"
end

start_line, end_line = bounds
if start_line < 1 || end_line < start_line
raise OperationError, "replace_exact: 'lines' must satisfy 1 <= start <= end, got #{start_line}..#{end_line}"
end

[start_line, end_line]
end

# Character range [start, end) covering the given 1-based inclusive
# line range, including the end line's terminator so a match may end
# at the end of that line.
def char_range_for_lines(start_line, end_line)
total = 0
box_start = nil
box_end = nil
pos = 0
@content.each_line do |line|
total += 1
box_start = pos if total == start_line
pos += line.length
box_end = pos if total == end_line
end

if box_start.nil? || box_end.nil?
raise OperationError, "replace_exact: lines #{start_line}..#{end_line} out of range (document has #{total} lines)"
end

[box_start, box_end]
end

def find_all_occurrences(text)
ranges = []
start_pos = 0
Expand Down
16 changes: 16 additions & 0 deletions engine/app/views/coplan/agent_instructions/show.text.erb
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,22 @@ Find and replace exact text. Fails if text not found or count exceeded.
}
```

Optional `lines` qualifier: a 1-based inclusive line range scoping the match — an
integer (single line) or a `[start, end]` pair. Use it to disambiguate text that
appears more than once (only occurrences fully inside the range are considered;
`occurrence`/`replace_all`/`count` then apply within it). Multi-line `old_text`
is allowed as long as the match fits inside the range. Fails if the text isn't
found within the range.

```json
{
"op": "replace_exact",
"old_text": "- [ ] Write tests",
"new_text": "- [x] Write tests",
"lines": 12
}
```

### insert_under_heading

Insert content after a markdown heading. Fails if heading not found or ambiguous.
Expand Down
Loading
Loading