Skip to content
Merged
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
28 changes: 28 additions & 0 deletions engine/app/assets/stylesheets/coplan/application.css
Original file line number Diff line number Diff line change
Expand Up @@ -1132,6 +1132,34 @@ img.avatar {
border-radius: 3px;
}

.markdown-rendered .mermaid-diagram {
display: flex;
justify-content: center;
overflow-x: auto;
margin-bottom: var(--space-md);
padding: var(--space-md);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
}

.markdown-rendered .mermaid-diagram svg {
max-width: 100%;
height: auto;
}

.markdown-rendered .mermaid-diagram--error {
border-color: var(--color-danger);
}

.markdown-rendered .mermaid-diagram__error-message {
display: block;
margin-bottom: var(--space-sm);
color: var(--color-danger);
font-family: var(--font-sans);
font-weight: 600;
}

.markdown-rendered blockquote {
border-left: 3px solid var(--color-border);
padding-left: var(--space-md);
Expand Down
4 changes: 2 additions & 2 deletions engine/app/helpers/coplan/markdown_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ 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 lang href src alt title type checked disabled data-line-text data-action data-coplan--checkbox-target data-mention-username].freeze

# Matches `[@username](mention:username)` where the bracket text and link
# target encode the same username. Username allows letters, digits, dots,
Expand All @@ -27,7 +27,7 @@ def render_markdown(content, interactive: true)
with_chips = transform_mention_anchors(html)
sanitized = sanitize(with_chips, tags: ALLOWED_TAGS, attributes: ALLOWED_ATTRIBUTES)
result = interactive ? make_checkboxes_interactive(sanitized, content) : sanitized
tag.div(result.html_safe, class: "markdown-rendered")
tag.div(result.html_safe, class: "markdown-rendered", data: { controller: "coplan--mermaid" })
end

# Replaces `<a href="mention:username">@username</a>` produced by
Expand Down
150 changes: 150 additions & 0 deletions engine/app/javascript/controllers/coplan/mermaid_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { Controller } from "@hotwired/stimulus"

let diagramId = 0
let mermaidPromise
let configuredTheme

export default class extends Controller {
connect() {
this.renderGeneration ||= 0
this.boundThemeChange = () => this.renderDiagrams()
this.colorSchemeQuery = window.matchMedia("(prefers-color-scheme: dark)")
window.addEventListener("coplan:theme-changed", this.boundThemeChange)
this.colorSchemeQuery.addEventListener("change", this.boundThemeChange)
this.renderDiagrams()
}

disconnect() {
this.renderGeneration += 1
window.removeEventListener("coplan:theme-changed", this.boundThemeChange)
this.colorSchemeQuery.removeEventListener("change", this.boundThemeChange)
}

async renderDiagrams() {
const sources = [
...Array.from(this.element.querySelectorAll('pre[lang="mermaid"] > code'), block => ({
container: block.parentElement,
source: block.textContent
})),
...Array.from(this.element.querySelectorAll(".mermaid-diagram[data-mermaid-source]"), diagram => ({
container: diagram,
source: diagram.dataset.mermaidSource
}))
]
if (sources.length === 0) return

let mermaid
try {
mermaid = await loadMermaid()
} catch {
sources.forEach(({ container }) => this.showError(container))
return
}

const theme = configureMermaid(mermaid)
const generation = ++this.renderGeneration

for (const { container, source } of sources) {
await this.renderDiagram(mermaid, container, source, theme, generation)
}
}

async renderDiagram(mermaid, sourceContainer, source, theme, generation) {
const id = `coplan-mermaid-${++diagramId}`

try {
const { svg, bindFunctions } = await mermaid.render(id, source)
if (!this.element.isConnected || generation !== this.renderGeneration) return

const diagram = document.createElement("div")
diagram.className = "mermaid-diagram"
diagram.setAttribute("role", "img")
diagram.setAttribute("aria-label", "Mermaid diagram")
diagram.dataset.mermaidSource = source
diagram.dataset.mermaidTheme = theme
diagram.innerHTML = svg
sourceContainer.replaceWith(diagram)

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 Preserve comment anchors when rendering Mermaid

When a thread is anchored to text inside a Mermaid fence, this replacement removes the only DOM text that coplan--text-selection can wrap with an anchor highlight, so existing/API-created feedback on diagram source loses its visible mark and cannot be opened from the plan document. This can also affect duplicate anchor text whose occurrence was calculated after the diagram was rendered and the source was hidden; preserve the source in the DOM or map the thread to a stable diagram/source view instead of replacing it outright.

Useful? React with 👍 / 👎.

bindFunctions?.(diagram)
} catch {
document.getElementById(id)?.remove()
document.getElementById(`d${id}`)?.remove()
this.showError(sourceContainer)
}
}

showError(sourceContainer) {
if (!sourceContainer.matches('pre[lang="mermaid"]')) return

sourceContainer.classList.add("mermaid-diagram--error")
sourceContainer.removeAttribute("lang")

const message = document.createElement("span")
message.className = "mermaid-diagram__error-message"
message.textContent = "Diagram could not be rendered."
sourceContainer.prepend(message)
}
}

function loadMermaid() {
if (mermaidPromise) return mermaidPromise

mermaidPromise = import("mermaid").then(({ default: mermaid }) => mermaid)
return mermaidPromise
}

function configureMermaid(mermaid) {
const explicitTheme = document.documentElement.dataset.theme
const dark = explicitTheme === "dark" ||
(!explicitTheme && window.matchMedia("(prefers-color-scheme: dark)").matches)
const theme = dark ? "dark" : "light"
if (theme === configuredTheme) return theme

mermaid.initialize({
startOnLoad: false,
securityLevel: "strict",
suppressErrorRendering: true,
theme: dark ? "base" : "default",
...(dark && { themeVariables: darkThemeVariables() })
})
configuredTheme = theme
return theme
}

function darkThemeVariables() {
return {
darkMode: true,
background: "#0f172a",
primaryColor: "#1e3a5f",
primaryBorderColor: "#60a5fa",
primaryTextColor: "#eff6ff",
secondaryColor: "#3b255f",
secondaryBorderColor: "#a78bfa",
secondaryTextColor: "#f5f3ff",
tertiaryColor: "#123f3a",
tertiaryBorderColor: "#34d399",
tertiaryTextColor: "#ecfdf5",
lineColor: "#93c5fd",
textColor: "#e5eefc",
mainBkg: "#1e3a5f",
nodeBorder: "#60a5fa",
clusterBkg: "#172554",
clusterBorder: "#818cf8",
edgeLabelBackground: "#152033",
actorBkg: "#312e81",
actorBorder: "#a5b4fc",
actorTextColor: "#eef2ff",
actorLineColor: "#64748b",
signalColor: "#7dd3fc",
signalTextColor: "#e0f2fe",
labelBoxBkgColor: "#123f3a",
labelBoxBorderColor: "#34d399",
labelTextColor: "#ecfdf5",
activationBkgColor: "#164e63",
activationBorderColor: "#22d3ee",
noteBkgColor: "#713f12",
noteBorderColor: "#fbbf24",
noteTextColor: "#fef3c7",
labelColor: "#eff6ff",
altBackground: "#3b255f"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export default class extends Controller {
if (meta) {
meta.content = theme === "system" ? "light dark" : theme
}
window.dispatchEvent(new CustomEvent("coplan:theme-changed"))

const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content
fetch(this.urlValue, {
Expand Down
14 changes: 14 additions & 0 deletions engine/app/views/coplan/agent_instructions/show.text.erb
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,20 @@ Returns: plan metadata, `current_content`, `current_revision`, `comment_threads`

Optional fields: `plan_type` (string) — the name of a plan type to use. See [Plan Types](#plan-types) below.

#### Diagrams

Plans support Mermaid diagrams. When a diagram communicates architecture, data flow, sequence, or state more clearly than prose, write a fenced `mermaid` block in the plan Markdown:

````markdown
```mermaid
flowchart LR
Client --> API
API --> Database
```
````

The web UI renders these blocks as diagrams. Keep the Mermaid source in the plan so humans and agents can review and edit it alongside the surrounding Markdown.

### Update Plan

Update plan metadata (title, status, tags). Only fields included in the request body are changed.
Expand Down
1 change: 1 addition & 0 deletions engine/config/importmap.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pin "@rails/actioncable", to: "actioncable.esm.js"
pin "coplan/web_push", to: "coplan/web_push.js"
pin "mermaid", to: "https://cdn.jsdelivr.net/npm/mermaid@11.16.0/dist/mermaid.esm.min.mjs", preload: false
pin_all_from CoPlan::Engine.root.join("app/javascript/controllers/coplan"), under: "controllers/coplan", preload: true
8 changes: 8 additions & 0 deletions spec/helpers/markdown_helper_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@
html = helper.render_markdown(nil)
expect(html).to include("markdown-rendered")
end

it "marks rendered markdown for Mermaid enhancement" do
html = helper.render_markdown("```mermaid\ngraph LR\n A --> B\n```")

expect(html).to include('data-controller="coplan--mermaid"')
expect(html).to include('<pre lang="mermaid"><code>')
expect(html).to include("graph LR")
end
end

describe "#render_line_view" do
Expand Down
1 change: 1 addition & 0 deletions spec/requests/agent_instructions_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
expect(response).to have_http_status(:success)
expect(response.content_type).to include("text/markdown")
expect(response.body).to include("# CoPlan API")
expect(response.body).to include("```mermaid")
end

it "includes plan types when they exist" do
Expand Down
38 changes: 38 additions & 0 deletions spec/system/comment_ux_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,44 @@ def create_anchored_thread(plan:, anchor_text:, body:, user:)
expect(page).to have_content("Architecture Overview")
expect(page).to have_content("microservices architecture")
end

it "renders Mermaid fences as diagrams" do
plan.current_plan_version.update!(content_markdown: <<~MARKDOWN)
# Request flow

```mermaid
flowchart LR
Client --> API
API --> Database
```
MARKDOWN

visit plan_path(plan)

expect(page).to have_css(".mermaid-diagram svg", wait: 10)
expect(page).not_to have_css('pre[lang="mermaid"]')

page.execute_script(<<~JS)
document.documentElement.dataset.theme = "dark"
window.dispatchEvent(new CustomEvent("coplan:theme-changed"))
JS
expect(page).to have_css('.mermaid-diagram[data-mermaid-theme="dark"]', wait: 10)
end

it "keeps invalid Mermaid source readable" do
plan.current_plan_version.update!(content_markdown: <<~MARKDOWN)
```mermaid
not a valid diagram
```
MARKDOWN

visit plan_path(plan)

expect(page).to have_css(".mermaid-diagram--error", wait: 10)
expect(page).to have_content("Diagram could not be rendered.")
expect(page).to have_content("not a valid diagram")
expect(page).not_to have_css('[id^="dcoplan-mermaid-"]', visible: :all)
end
end

describe "inline highlights" do
Expand Down
Loading