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
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# This migration comes from co_plan (originally 20261008000000)
class CreateCoplanEmbedDomains < ActiveRecord::Migration[8.0]
def change
create_table :coplan_embed_domains, id: :string, limit: 36 do |t|
t.string :hostname, null: false
t.timestamps
end
add_index :coplan_embed_domains, :hostname, unique: true
end
end
9 changes: 8 additions & 1 deletion db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

31 changes: 27 additions & 4 deletions docs/human-document-editor.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@ incoming edits map through both editors without focusing the background pane. So
select the block's source. The source mode has ordinary text Enter/Tab and
Cmd/Ctrl+Z/Shift+Z; rich toolbar controls remain visible but disabled there.

Tables render as table previews with **Edit Markdown** above them. Edit table
cells/rows/alignment in source, then switch back to see the result. There are no
Tables render as previews with **Edit table** above them. Edit table
cells/rows/alignment in the table’s Markdown field to see the result. There are no
visual table insertion or cell-editing controls in this prototype.

Mermaid fences show editable Mermaid source plus a rendered diagram preview.
Expand All @@ -57,12 +57,29 @@ the theme. Its expand button opens the existing diagram viewer. **Edit Markdown*
selects the whole fence for exact source editing. Invalid/offline previews keep
the source available; they do not discard or replace it.

Other unsupported Markdown (task lists, footnotes, raw HTML, reference-style
Footnote citations stay inline in editable paragraphs and lists. Click a citation (or **Edit** beside its reference at the bottom)
to open its definition in a centered dialog. Save stores the definition in Markdown,
with revision checks and the normal autosave pipeline. Definitions stay hidden in the
rich body; Raw retains their exact source. Cancel leaves the definition unchanged.

Each presentation region is a single block with a large **Presentation Markdown**
field and a slide-screen icon. **Preview** uses the normal slideshow renderer;
**Edit Markdown** returns to the field. Edit slides there; the region's ID and theme remain in its enclosing markers.
Unknown or unclosed regions stay visible source.

Tables remain separate nodes even inside a list. **Edit table** opens a Markdown
field for only that table, keeping the surrounding list rich and editable.

**+ → Embed** adds a self-closing iframe block with URL, title, width,
and height fields. Administrators approve exact HTTPS hosts under **Iframe domains**.
No domains are allowed by default. See the served `/agent-instructions/embeds`
guide for syntax, supported sizes, and the fixed sandbox and frame policy.

Other unsupported Markdown (task lists, raw HTML, reference-style
links, strikethrough and mentions) remains source-preserving. Cards show a
sanitized reading-style preview when available, with the same source-edit path.
Untouched blocks retain their exact spelling. A supported block that you edit
in rich mode is reserialized; Markdown mode gives full control over spelling.
Slides remain deferred.

## Code blocks

Expand Down Expand Up @@ -234,3 +251,9 @@ A reported case where Enter appeared inert inside a code block has not been
reproduced in Chrome, the in-app browser, or a disposable copy of the saved
content. Passing caret tests do not establish that report's cause or resolution.
The original open draft was unavailable for inspection and was left untouched.

An expired sign-in keeps the draft in the current tab and shows **Sign in again · draft retained**. Sign in in another tab, then retry the original editor. Its uncached editor-state response refreshes the security token before the next save. Citation links use matching labels: `[^catalog]` in the text and `[^catalog]: Source text` in the definitions. Editing the source text in the citation dialog preserves that link; renaming a label in Markdown requires changing both places.

Unsaved new plans refresh their security token from the uncached new-plan form and retry with the same creation key. Live citation replacements include the same dialog controls as the initial page.

Citation labels match using Unicode case folding and collapsed whitespace; dialog edits preserve the original spelling of both the marker and definition. An open presentation preview refreshes when Undo, Redo, or a live update changes its source, and ignores older preview responses.
18 changes: 18 additions & 0 deletions engine/app/admin/embed_domains.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
ActiveAdmin.register CoPlan::EmbedDomain, as: "EmbedDomain" do
menu label: "Iframe domains"
permit_params :hostname

index do
selectable_column
column :hostname
column :created_at
actions
end

form do |f|
f.inputs "Allowed iframe domain" do
f.input :hostname, hint: "Exact host only, for example www.openstreetmap.org. HTTPS only. Subdomains must be approved separately."
end
f.actions
end
end
37 changes: 37 additions & 0 deletions engine/app/assets/stylesheets/coplan/application.css
Original file line number Diff line number Diff line change
Expand Up @@ -8220,3 +8220,40 @@ html[data-theme="light"] .document-editor select, html[data-theme="light"] .docu
.thread-popover__permalink:focus-visible .comment-window__copy-feedback,
.thread-popover__permalink[data-copied] .comment-window__copy-feedback,
.thread-popover__permalink[data-copy-failed] .comment-window__copy-feedback { opacity: 1; }


.document-editor__footnote { color: var(--color-primary); font-size: 0.7em; white-space: nowrap; }
.document-editor__footnote button { border: 0; background: transparent; color: inherit; padding: 0 2px; font: inherit; cursor: pointer; border-radius: 3px; }
.document-editor__footnote button:hover { background: var(--color-interaction-hover-bg); }
.document-editor__footnote button:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; }
.document-editor__block-source { display: block; width: 100%; min-height: 320px; resize: vertical; border: 0; padding: 18px; background: var(--color-surface); color: var(--color-text); font: 14px/1.7 var(--font-mono, monospace); box-sizing: border-box; }
.document-editor__block-source:focus { outline: 2px solid var(--color-primary); outline-offset: -2px; }
.document-editor__embed-fields { padding: 16px; display: grid; grid-template-columns: 1fr 1fr; gap: 12px; font: 13px/1.5 var(--font-sans, sans-serif); }
.document-editor__embed-fields label { display: flex; flex-direction: column; gap: 4px; }
.document-editor__embed-fields label:first-child { grid-column: 1 / -1; }
.document-editor__embed-fields input, .document-editor__citation-dialog textarea { width: 100%; box-sizing: border-box; border: 1px solid var(--color-border); border-radius: 6px; padding: 9px 10px; background: var(--color-surface); color: var(--color-text); font: inherit; }
.document-editor__citation-dialog { position: fixed; inset: 0; margin: auto; max-height: calc(100dvh - 40px); overflow: auto; width: min(560px, calc(100vw - 40px)); border: 1px solid var(--color-border); border-radius: 16px; padding: 24px; background: var(--color-surface); color: var(--color-text); box-shadow: 0 16px 60px #0004; }
.document-editor__citation-dialog::backdrop { background: #0005; }
.document-editor__citation-heading, .document-editor__citation-actions { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.document-editor__citation-heading h2 { margin: 0 0 16px; font-size: 18px; }
.document-editor__citation-heading button { border: 0; background: transparent; color: var(--color-text-muted); font-size: 24px; cursor: pointer; }
.document-editor__citation-dialog label { display: block; margin-bottom: 8px; font-size: 13px; }
.document-editor__citation-dialog textarea { font: 14px/1.6 var(--font-mono, monospace); resize: vertical; }
.document-editor__citation-actions { justify-content: flex-end; }
.document-editor__citation-error { color: var(--color-danger); min-height: 1.5em; font-size: 13px; }
.iframe-content-block { margin: 20px 0; max-width: 100%; }
.iframe-content-block iframe { display: block; max-width: 100%; border: 1px solid var(--color-border); border-radius: 8px; }
.iframe-content-block__unavailable { padding: 18px; border: 1px solid var(--color-border); border-radius: 8px; color: var(--color-text-muted); }

.document-editor__embed-hint { margin: 0; padding: 0 16px 10px; font: 12px/1.5 var(--font-sans, sans-serif); color: var(--color-text-muted); }

.citation-edit-control { display: none; }
[data-editing="true"] .citation-edit-control { display: inline-block; border: 0; background: transparent; color: var(--color-primary); cursor: pointer; padding: 4px 8px; border-radius: 4px; }
[data-editing="true"] .citation-edit-control:hover { background: var(--color-interaction-hover-bg); }

.document-editor__presentation-label { display: inline-flex; align-items: center; gap: 0.5rem; }
.document-editor__presentation-label svg { width: 1.25rem; height: 1.25rem; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }

.document-editor__content-picker { width: 200px; }
.document-editor__content-picker > button { display: flex; align-items: center; justify-content: flex-start; gap: 10px; width: 100%; height: auto; padding: 10px; text-align: left; font-size: 14px; border-radius: 6px; color: var(--color-text); }
.document-editor__content-picker > button:hover, .document-editor__content-picker > button:focus-visible { background: var(--color-primary-light); color: var(--color-primary); }
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ class AgentInstructionsController < ApplicationController
GUIDES = {
"markdown" => "markdown",
"presentations" => "presentations",
"embeds" => "embeds",
"creating" => "creating",
"editing" => "editing",
"comments" => "comments",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
module CoPlan
module Api
module V1
class EmbedDomainsController < BaseController
def index
render json: { hostnames: EmbedDomain.order(:hostname).pluck(:hostname) }
end
end
end
end
end
22 changes: 22 additions & 0 deletions engine/app/controllers/coplan/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ def self.controller_path
before_action :set_coplan_current
after_action :set_agent_instructions_header
after_action :track_page_view
after_action :restrict_embedded_frames

helper_method :current_user, :signed_in?, :show_api_tokens?

Expand All @@ -44,6 +45,27 @@ class NotAuthorizedError < StandardError; end

private

# A second CSP composes with any stricter host policy. It also constrains
# frame redirects, rather than trusting only the initial iframe URL.
def restrict_embedded_frames
return unless response.media_type == "text/html"

hosts = EmbedDomain.order(:hostname).pluck(:hostname).reject { |host| host == request.host.downcase }
policy = "frame-src #{hosts.empty? ? "'none'" : hosts.map { |host| "https://#{host}" }.join(' ')}"
existing = response.headers["Content-Security-Policy"]
# Rails' middleware skips generating its policy when a response already
# has a CSP header. Build the host policy first so we do not replace it.
if (host_policy = request.content_security_policy)
configured = host_policy.build(self, request.content_security_policy_nonce, request.content_security_policy_nonce_directives)
if request.content_security_policy_report_only
response.headers["Content-Security-Policy-Report-Only"] ||= configured
else
existing = [ existing.presence, configured.presence ].compact.join(", ")
end
end
response.headers["Content-Security-Policy"] = [ existing.presence, policy ].compact.join(", ")
end

def current_user
@current_coplan_user
end
Expand Down
7 changes: 5 additions & 2 deletions engine/app/controllers/coplan/plans_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,8 @@ def update
end

def new
response.headers["Cache-Control"] = "no-store"
response.headers["X-CSRF-Token"] = form_authenticity_token
@new_plan_type = PlanType.find_by(id: params[:plan_type_id]) if params[:plan_type_id].present?
return head :not_found if params[:plan_type_id].present? && !@new_plan_type
@new_folder = current_user.library.folders.find_by(id: params[:folder_id]) if params[:folder_id].present?
Expand Down Expand Up @@ -249,6 +251,7 @@ def create
def editor_state
authorize!(@plan, :edit_content?)
response.headers["Cache-Control"] = "no-store"
response.headers["X-CSRF-Token"] = form_authenticity_token
render json: editor_snapshot
end

Expand All @@ -263,7 +266,7 @@ def editor_lease
end

def preview_draft
render html: helpers.render_markdown(params[:content].to_s, interactive: false), layout: false
render html: helpers.render_content_regions(params[:content].to_s, interactive: false), layout: false
end

# Human whole-document editing goes through the same pipeline as agent
Expand Down Expand Up @@ -311,7 +314,7 @@ def update_content
# render: no checkbox wiring, since the content isn't saved yet.
def preview
authorize!(@plan, :show?)
html = helpers.render_markdown(params[:content].to_s, interactive: false)
html = helpers.render_content_regions(params[:content].to_s, interactive: false)
render html: html, layout: false
end

Expand Down
34 changes: 34 additions & 0 deletions engine/app/helpers/coplan/embeds_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
module CoPlan
module EmbedsHelper
def render_iframe_blocks(html, source)
return html unless source.include?(ContentRegions::Iframe::PREFIX)

lines = source.lines
doc = Nokogiri::HTML::DocumentFragment.parse(html)
# Only root-level, standalone paragraphs are content blocks. A marker
# written in inline/fenced code, a quotation or a list stays literal.
doc.children.select { |node| node.name == "p" }.each do |paragraph|
pos = /\A(\d+):\d+-\1:\d+\z/.match(paragraph["data-sourcepos"].to_s)
next unless pos

block = ContentRegions::Iframe.call(lines[pos[1].to_i - 1].to_s.chomp)
next unless block

markup = if block.allowed?(host: request&.host)
attrs = block.attributes
width = attrs["width"].end_with?("%") ? attrs["width"] : "#{attrs['width']}px"
tag.div(class: "iframe-content-block") do
tag.iframe("", src: attrs["src"], title: attrs["title"], height: attrs["height"],
style: "width: #{width}", sandbox: "allow-scripts allow-forms allow-same-origin", loading: "lazy",
referrerpolicy: "strict-origin-when-cross-origin", allow: "camera 'none'; microphone 'none'; geolocation 'none'")
end
else
tag.div("Embedded page unavailable. Check its URL, size, and administrator-approved domain.",
class: "iframe-content-block__unavailable", role: "status")
end
paragraph.replace(Nokogiri::HTML::DocumentFragment.parse(markup))
end
doc.to_html
end
end
end
11 changes: 9 additions & 2 deletions engine/app/helpers/coplan/markdown_helper.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
module CoPlan
module MarkdownHelper
include EmbedsHelper
ALLOWED_TAGS = %w[
h1 h2 h3 h4 h5 h6
p div span
Expand Down Expand Up @@ -34,7 +35,7 @@ module MarkdownHelper
# version. Bump it whenever the rendering pipeline changes output for the
# same input (new tags, attribute changes, checkbox wiring, etc.), or
# stale HTML will be served from cache.
RENDER_CACHE_VERSION = 19
RENDER_CACHE_VERSION = 20

# Matches `[@username](mention:username)` where the bracket text and link
# target encode the same username. Username allows letters, digits, dots,
Expand Down Expand Up @@ -64,12 +65,18 @@ def render_markdown(content, interactive: true, footnote_prefix: nil, footnotes:
render_options = { unsafe: true }
# Sourcepos wires checkboxes and structural comment targets to source;
# it is stripped after generating trusted interaction metadata.
render_options[:sourcepos] = true if interactive
render_options[:sourcepos] = true if interactive || content.to_s.include?(ContentRegions::Iframe::PREFIX)
html = Commonmarker.to_html(content.to_s.encode("UTF-8"), options: { extension: EXTENSION_OPTIONS, render: render_options }, plugins: { syntax_highlighter: nil })
with_chips = transform_mention_anchors(html)
with_references = transform_reference_anchors(with_chips, numbered_sections: footnote_prefix.nil?)
sanitized = sanitize(with_references, tags: ALLOWED_TAGS, attributes: ALLOWED_ATTRIBUTES)
sanitized = render_iframe_blocks(sanitized, content.to_s)
result = interactive ? make_checkboxes_interactive(sanitized, content, line_offset: line_offset, source_comments: source_comments, retain_sourcepos: retain_sourcepos) : sanitized
unless interactive
fragment = Nokogiri::HTML::DocumentFragment.parse(result)
fragment.css("[data-sourcepos]").each { |node| node.remove_attribute("data-sourcepos") }
result = fragment.to_html
end
result = scope_footnote_ids(result, footnote_prefix) if footnote_prefix
result = select_footnotes(result, footnotes)
return result.html_safe if footnotes == :only
Expand Down
12 changes: 12 additions & 0 deletions engine/app/helpers/coplan/references_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,18 @@ def plan_citation_back_matter(plan, references)
}
end

def citation_edit_controls(html)
doc = Nokogiri::HTML::DocumentFragment.parse(html)
doc.css("section[data-footnotes] > ol > li[id]").each do |entry|
label = entry["id"].delete_prefix("fn-")
button = tag.button("Edit", type: "button", class: "citation-edit-control",
aria: { label: "Edit reference #{label}" },
data: { citation_label: label, action: "coplan--inline-editor#editCitation" })
entry.add_child(Nokogiri::HTML::DocumentFragment.parse(button))
end
doc.to_html.html_safe
end

def listed_plan_references(references, cited_urls)
references.reject { |reference| cited_urls.include?(reference.url) }
end
Expand Down
Loading
Loading