diff --git a/engine/app/assets/stylesheets/coplan/application.css b/engine/app/assets/stylesheets/coplan/application.css index 520d310a..aaa8f4b0 100644 --- a/engine/app/assets/stylesheets/coplan/application.css +++ b/engine/app/assets/stylesheets/coplan/application.css @@ -2977,6 +2977,296 @@ body:not(:has(.comment-toolbar)) .web-push-banner { color: var(--color-text-muted); } +.landing__footer-oss { + margin: var(--space-sm) 0 0; +} + +.landing__kicker { + display: inline-block; + margin: 0 0 var(--space-md); + padding: 0.25rem 0.75rem; + border: 1px solid var(--color-border); + border-radius: 999px; + background: var(--color-surface); + font-size: var(--text-sm); + font-weight: 500; + color: var(--color-text-muted); + letter-spacing: 0.01em; +} + +.landing__features { + margin-bottom: var(--space-2xl); +} + +.landing__feature-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); + gap: var(--space-lg); +} + +.landing__feature { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); + padding: var(--space-lg); +} + +.landing__feature h3 { + font-size: var(--text-base); + font-weight: 600; + margin: 0 0 var(--space-sm); + color: var(--color-text); +} + +.landing__feature p { + margin: 0; + font-size: var(--text-sm); + line-height: 1.6; + color: var(--color-text-muted); +} + +/* Copy-to-clipboard URL chip — shared partial coplan/shared/_copy_url */ +.copy-url { + display: flex; + align-items: stretch; + gap: var(--space-sm); + flex-wrap: wrap; +} + +.copy-url__value { + flex: 1 1 16rem; + display: flex; + align-items: center; + padding: 0.4rem 0.75rem; + background: var(--color-code-bg); + border: 1px solid var(--color-border); + border-radius: var(--radius); + font-family: var(--font-mono); + font-size: var(--text-sm); + color: var(--color-text); + overflow-wrap: anywhere; +} + +.landing__agents .copy-url { + margin-top: var(--space-md); +} + +/* Landing mock — an illustrative, CSS-only rendering of the review loop + (anchored comment → agent edit → new version). Uses the same design tokens + as the real plan UI so it stays truthful in both light and dark themes. */ +.landing-mock { + display: grid; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + gap: var(--space-lg); + align-items: start; + margin-bottom: var(--space-2xl); +} + +.landing-mock__doc { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); + box-shadow: var(--shadow); + overflow: hidden; +} + +.landing-mock__doc-header { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-md) var(--space-lg); + border-bottom: 1px solid var(--color-border); + background: var(--color-surface-muted); +} + +.landing-mock__doc-title { + font-weight: 600; + font-size: var(--text-sm); + color: var(--color-text); + margin-right: auto; +} + +.landing-mock__badge { + padding: 0.1rem 0.5rem; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 600; +} + +.landing-mock__badge--status { + background: var(--color-status-considering-bg); + color: var(--color-status-considering); +} + +.landing-mock__badge--version { + background: var(--color-tag-bg); + color: var(--color-tag-text); +} + +.landing-mock__doc-body { + padding: var(--space-lg); +} + +.landing-mock__heading { + margin: 0 0 var(--space-sm); + font-size: var(--text-base); + font-weight: 600; + color: var(--color-text); +} + +.landing-mock__line { + margin: 0 0 var(--space-sm); + font-size: var(--text-sm); + line-height: 1.7; + color: var(--color-text); +} + +.landing-mock__line--dim { + color: var(--color-text-muted); +} + +.landing-mock__highlight { + background: var(--color-highlight-pending-bg); + border-bottom: 2px solid var(--color-highlight-pending-border); + color: inherit; + padding: 0 0.1em; + border-radius: 2px; +} + +.landing-mock__thread { + display: flex; + flex-direction: column; + gap: var(--space-sm); +} + +.landing-mock__comment { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: var(--space-md); +} + +.landing-mock__comment p { + margin: 0; + font-size: var(--text-sm); + line-height: 1.6; + color: var(--color-text); +} + +.landing-mock__comment--agent { + border-color: var(--color-agent-bg); + background: var(--color-surface-muted); + margin-left: var(--space-lg); +} + +.landing-mock__author { + font-size: 0.75rem; + font-weight: 600; + color: var(--color-text-muted); + margin-bottom: var(--space-xs); +} + +.landing-mock__agent-chip { + display: inline-block; + margin-left: 0.25rem; + padding: 0 0.4rem; + border-radius: 999px; + background: var(--color-agent-bg); + color: var(--color-agent); + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.landing-mock__provenance { + margin-top: var(--space-sm); + font-family: var(--font-mono); + font-size: 0.75rem; + color: var(--color-text-muted); +} + +/* Copy-to-clipboard button feedback (see clipboard_controller.js) */ +.copy-button--copied { + background: var(--color-success); + border-color: var(--color-success); + color: var(--color-text-inverse); +} + +/* Agent instructions — human-friendly HTML rendering at /agent-instructions. + Agents and curl still get raw markdown from the same URL; this page is what + browsers see. */ +.agent-instructions { + max-width: 52rem; + margin: 0 auto; + padding: var(--space-2xl) var(--space-lg); +} + +.agent-instructions__header { + margin-bottom: var(--space-2xl); +} + +.agent-instructions__title { + font-size: 2rem; + font-weight: 700; + line-height: 1.2; + margin: 0 0 var(--space-md); + color: var(--color-text); +} + +.agent-instructions__lede { + font-size: var(--text-lg); + line-height: 1.6; + color: var(--color-text-muted); + margin: 0 0 var(--space-xl); +} + +.agent-instructions__url-card { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: var(--space-lg); + margin-bottom: var(--space-lg); +} + +.agent-instructions__url-label { + font-size: var(--text-sm); + font-weight: 600; + color: var(--color-text-muted); + margin-bottom: var(--space-sm); +} + +.agent-instructions__fetch-hint { + font-size: var(--text-sm); + color: var(--color-text-muted); + line-height: 1.6; + margin: 0 0 var(--space-sm); +} + +.agent-instructions__curl { + margin: 0 0 var(--space-sm); + padding: var(--space-md); + background: var(--color-code-bg); + border: 1px solid var(--color-border); + border-radius: var(--radius); + font-family: var(--font-mono); + font-size: var(--text-sm); + color: var(--color-text); + overflow-x: auto; +} + +.agent-instructions__raw-link { + color: var(--color-primary); + text-decoration: underline; +} + +.agent-instructions__doc { + border-top: 1px solid var(--color-border); + padding-top: var(--space-xl); +} + @media (max-width: 640px) { .landing { padding: var(--space-xl) var(--space-md); @@ -2985,6 +3275,18 @@ body:not(:has(.comment-toolbar)) .web-push-banner { .landing__title { font-size: 1.75rem; } + + .landing-mock { + grid-template-columns: 1fr; + } + + .landing-mock__comment--agent { + margin-left: var(--space-md); + } + + .agent-instructions { + padding: var(--space-xl) var(--space-md); + } } /* Stale-content banner — shown when the plan body was updated in another tab diff --git a/engine/app/controllers/coplan/agent_instructions_controller.rb b/engine/app/controllers/coplan/agent_instructions_controller.rb index 192128af..82d3b4e4 100644 --- a/engine/app/controllers/coplan/agent_instructions_controller.rb +++ b/engine/app/controllers/coplan/agent_instructions_controller.rb @@ -1,4 +1,26 @@ module CoPlan + # Serves the agent API instructions at /agent-instructions. + # + # This endpoint has two audiences: + # + # * **Agents and CLIs** (curl, HTTP libraries, coding agents) fetch it as raw + # Markdown. Every API response points here via the `X-Agent-Instructions` + # header, so the raw behavior is load-bearing: any client that does not + # explicitly ask for HTML gets `text/markdown`, byte-identical to what this + # endpoint has always served. + # * **Humans in a browser** (e.g. clicking the link on the landing page) get + # the same instructions rendered as a styled HTML page, with the raw URL + # front-and-center so they can hand it to their agent. + # + # Negotiation is deliberately conservative: we only serve HTML when the + # Accept header *leads* with `text/html`, which is exactly what every + # browser sends (`Accept: text/html,application/xhtml+xml,…`) and what + # Turbo Drive sends on navigation (`Accept: text/html, application/xhtml+xml`). + # curl's default `Accept: */*`, an absent Accept header, or + # `Accept: text/markdown` all fall through to raw Markdown. An explicit + # format always wins over the Accept header: `/agent-instructions.md` + # forces raw Markdown (so browsers can view the source document) and + # `/agent-instructions.html` forces the rendered page. class AgentInstructionsController < ApplicationController skip_before_action :authenticate_coplan_user! @@ -11,7 +33,34 @@ def show # carries the request's SCRIPT_NAME. @base = "#{request.base_url}#{root_path.chomp("/")}" @plan_types = PlanType.order(:name) - render layout: false, content_type: "text/markdown", formats: [:text] + + if prefers_html? + # The page is public, but signed-in visitors should still see their + # normal nav chrome (search, inbox, sign-out) in the shared layout — + # same optional-resolve approach as WelcomeController. + @current_coplan_user = CoPlan::Authentication.user_from_request(request) + CoPlan::Current.user = current_user + + @instructions_url = coplan.agent_instructions_url + @instructions_markdown = render_to_string(:show, formats: [:text], layout: false) + render :show, formats: [:html] + else + render layout: false, content_type: "text/markdown", formats: [:text] + end + end + + private + + def prefers_html? + return true if params[:format] == "html" + return false if params[:format].present? + + # Intentionally a string check on the raw header rather than + # `request.format`/`request.accepts`: Rails maps curl's `*/*` to HTML, + # which would break every agent that follows X-Agent-Instructions here. + # Requiring the header to lead with text/html matches browsers exactly + # and nothing else. + request.headers["Accept"].to_s.strip.start_with?("text/html") end end end diff --git a/engine/app/javascript/controllers/coplan/clipboard_controller.js b/engine/app/javascript/controllers/coplan/clipboard_controller.js index 6b31d06a..5950f4a0 100644 --- a/engine/app/javascript/controllers/coplan/clipboard_controller.js +++ b/engine/app/javascript/controllers/coplan/clipboard_controller.js @@ -1,35 +1,64 @@ import { Controller } from "@hotwired/stimulus" -// Copies a preset string (data-coplan--clipboard-text-value) to the -// clipboard and gives brief visual feedback on the trigger button. -// Used by the attachments list to copy markdown embed snippets. +// Copies text to the clipboard and gives lightweight "Copied!" feedback on +// the button that triggered it. The text comes from the `text` value when +// present, otherwise from the `source` target's text content — so the same +// controller works for "copy this URL" chips and "copy this snippet" blocks. +// +// Usage: +//
+// +//
export default class extends Controller { + static targets = ["source", "button"] static values = { text: String } - static targets = ["button"] async copy() { + const text = this.hasTextValue && this.textValue !== "" + ? this.textValue + : this.sourceTarget.textContent.trim() + try { - await navigator.clipboard.writeText(this.textValue) - this.flash("✓ Copied") + await navigator.clipboard.writeText(text) + this.#flash("Copied!") } catch { - this.flash("Copy failed") + // Clipboard API unavailable (e.g., non-secure context). Fall back to + // selecting the source text so the user can copy manually. + this.#flash("Press ⌘C to copy") + if (this.hasSourceTarget) { + const range = document.createRange() + range.selectNodeContents(this.sourceTarget) + const selection = window.getSelection() + selection.removeAllRanges() + selection.addRange(range) + } } } - flash(label) { + #flash(message) { if (!this.hasButtonTarget) return + const button = this.buttonTarget - if (button.dataset.originalLabel === undefined) { + if (!button.dataset.originalLabel) { button.dataset.originalLabel = button.textContent } - button.textContent = label + button.textContent = message + button.classList.add("copy-button--copied") + clearTimeout(this.resetTimer) this.resetTimer = setTimeout(() => { button.textContent = button.dataset.originalLabel - }, 1500) + button.classList.remove("copy-button--copied") + }, 2000) } disconnect() { clearTimeout(this.resetTimer) + // Restore the label so Turbo's page cache never snapshots a stale + // "Copied!" state. + if (this.hasButtonTarget && this.buttonTarget.dataset.originalLabel) { + this.buttonTarget.textContent = this.buttonTarget.dataset.originalLabel + this.buttonTarget.classList.remove("copy-button--copied") + } } } diff --git a/engine/app/views/coplan/agent_instructions/show.html.erb b/engine/app/views/coplan/agent_instructions/show.html.erb new file mode 100644 index 00000000..1ee21d81 --- /dev/null +++ b/engine/app/views/coplan/agent_instructions/show.html.erb @@ -0,0 +1,33 @@ +<% content_for :title, "Agent Instructions — CoPlan" %> + +
+
+

Connect your AI agent

+

+ CoPlan's API is self-describing. Give your agent — Claude Code, Amp, Cursor, or your own — + the URL below plus an API token, and it can create plans, apply edits, and respond to + review comments on your behalf. +

+ +
+
Instructions URL — paste this into your agent
+ <%= render "coplan/shared/copy_url", url: @instructions_url, button_class: "btn btn--primary" %> +
+ +
+

+ Agents fetch this same URL and receive the instructions as raw Markdown: +

+
curl -s <%= @instructions_url %>
+

+ API tokens are created under Settings → API Tokens. You can also + <%= link_to "view the raw markdown", agent_instructions_path(format: :md), class: "agent-instructions__raw-link" %> + that agents see. +

+
+
+ +
+ <%= render_markdown(@instructions_markdown, interactive: false) %> +
+
diff --git a/engine/app/views/coplan/shared/_copy_url.html.erb b/engine/app/views/coplan/shared/_copy_url.html.erb new file mode 100644 index 00000000..d7d7d2da --- /dev/null +++ b/engine/app/views/coplan/shared/_copy_url.html.erb @@ -0,0 +1,13 @@ +<%# Copy-to-clipboard URL chip (see clipboard_controller.js). + Locals: + url — the URL to display and copy (required) + button_class — CSS classes for the copy button (optional) %> +
+ <%= url %> + +
diff --git a/engine/app/views/coplan/welcome/_default_agents.html.erb b/engine/app/views/coplan/welcome/_default_agents.html.erb index 6cb4be29..411ffdcc 100644 --- a/engine/app/views/coplan/welcome/_default_agents.html.erb +++ b/engine/app/views/coplan/welcome/_default_agents.html.erb @@ -1,9 +1,10 @@

Built for any AI agent

- CoPlan exposes a self-describing REST API at - <%= link_to "/agent-instructions", agent_instructions_path, class: "landing__inline-link" %>. - Point any AI agent — Amp, Claude Code, Cursor, your own — at it and it can create plans, - apply edits, and leave review comments on your behalf. + CoPlan exposes a self-describing REST API. Point any AI agent — Amp, Claude Code, Cursor, + your own — at <%= link_to "/agent-instructions", agent_instructions_path, class: "landing__inline-link" %> + and it can create plans, apply edits, and leave review comments on your behalf. + Agents authenticate with a bearer token from Settings → API Tokens.

+ <%= render "coplan/shared/copy_url", url: agent_instructions_url %>
diff --git a/engine/app/views/coplan/welcome/_default_landing.html.erb b/engine/app/views/coplan/welcome/_default_landing.html.erb index 183e9bde..d93b4688 100644 --- a/engine/app/views/coplan/welcome/_default_landing.html.erb +++ b/engine/app/views/coplan/welcome/_default_landing.html.erb @@ -1,22 +1,61 @@
+

Humans comment. AI agents edit.

Design docs, built for AI-assisted planning.

- CoPlan is where engineers and AI agents collaborate on plans together. - Humans leave inline feedback. AI agents respond to that feedback and apply edits automatically. - Every change is versioned with full provenance. + CoPlan gives your plans a home where teammates and AI agents make them better together. + Domain experts leave inline feedback, Google-Docs-style. Agents respond to that feedback + and apply the edits. Every change becomes an immutable version with full provenance — + who changed what, why, and which comment triggered it.

<% if signed_in? %> <%= link_to "Browse plans", plans_path, class: "btn btn--primary" %> - <% elsif main_app.respond_to?(:sign_in_path) %> - <%= link_to "Sign in", main_app.sign_in_path, class: "btn btn--primary" %> + <% elsif CoPlan.configuration.sign_in_path %> + <%= link_to "Sign in to get started", CoPlan.configuration.sign_in_path, class: "btn btn--primary" %> <% end %> - <%= link_to "Read the agent API", agent_instructions_path, class: "btn btn--secondary" %> + <%= link_to "Connect your agent", agent_instructions_path, class: "btn btn--secondary" %>
+ <%# Illustrative mock of the review loop: a reviewer anchors a comment to a + sentence, and an agent applies the edit — producing a new version. Pure + HTML/CSS, no screenshots, so it inherits light/dark theming for free. %> + +

How it works

@@ -25,9 +64,9 @@
1

Write a plan

- A plan is a Markdown design doc. Author it yourself, or have an AI agent draft one - for you using the API. Plans live through a clear lifecycle: brainstorm → considering - → developing → live. + A plan is a Markdown design doc. Draft it yourself or have an agent write it + through the API. Plans move through a clear lifecycle — brainstorm → considering + → developing → live — and brainstorms stay private until you publish.

@@ -35,9 +74,9 @@
2

Get inline feedback

- Domain experts highlight any sentence and leave a comment. Threads stay anchored to the - exact text they reference, so context never gets lost. Mention people with - @username to pull them in. + Reviewers highlight any sentence and leave a threaded comment anchored to that exact + text, so context never gets lost. Mention people with @username to pull + them in. New comments appear for every viewer in realtime.

@@ -45,9 +84,65 @@
3

Agents apply the edits

- AI agents read the comments, apply targeted edits to the plan, and resolve the threads. - Every edit creates a new immutable version with full attribution — you can see exactly - who (or what) changed what, and why. + You triage the feedback; agents do the typing. An agent reads the accepted comments, + applies targeted edits, and resolves the threads. Each edit creates a new immutable + version with full attribution — the actor, the model, and the comments that prompted it. +

+ + + + +
+

What you get

+ +
+
+

Inline, anchored comments

+

+ Select text in the rendered plan to start a thread, Google-Docs-style. Comments stay + pinned to the words they reference through triage: accept, resolve, or discard. +

+
+ +
+

Immutable versions with provenance

+

+ Every edit produces a new version recording who (or what) made it, the AI model and + prompt used, and which comments triggered the change. History is always browsable + and never rewritten. +

+
+ +
+

Realtime collaboration

+

+ Comments, edits, status changes, and new versions broadcast instantly to everyone + viewing the plan — no refresh needed. +

+
+ +
+

A semantic agent API

+

+ Agents edit through operations like replace_exact and + insert_under_heading — not brittle line numbers. An edit lease keeps + one agent writing at a time, and stale revisions are rejected cleanly. +

+
+ +
+

Humans comment, agents edit

+

+ The editing model is intentionally asymmetric: people review and decide, agents type. + That keeps the version history clean, auditable, and tied to a reason for every change. +

+
+ +
+

Works with any agent

+

+ Connecting an agent takes a bearer token and one URL — the API describes itself at + /agent-instructions, and every API response points back to it.

@@ -61,12 +156,16 @@ page. See `CoPlan.configuration.landing_agents_partial`. %> <%= render CoPlan.configuration.landing_agents_partial %> - <% if signed_in? %> - diff --git a/spec/requests/agent_instructions_spec.rb b/spec/requests/agent_instructions_spec.rb index a14ce98c..4b0668b3 100644 --- a/spec/requests/agent_instructions_spec.rb +++ b/spec/requests/agent_instructions_spec.rb @@ -56,4 +56,89 @@ expect(response.body).to include("http://www.example.com/coplan/api/v1/plans") end end + + describe "content negotiation" do + # What Chrome/Firefox/Safari actually send. + let(:browser_accept) { "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8" } + + context "non-browser clients (agents, curl)" do + it "serves raw markdown for curl's default Accept: */*" do + get agent_instructions_path, headers: { "Accept" => "*/*" } + + 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).not_to include(" "text/markdown" } + + expect(response.content_type).to include("text/markdown") + expect(response.body).to include("# CoPlan API") + end + + it "serves byte-identical markdown regardless of non-HTML Accept header" do + get agent_instructions_path + baseline = response.body + + get agent_instructions_path, headers: { "Accept" => "*/*" } + expect(response.body).to eq(baseline) + + get agent_instructions_path, headers: { "Accept" => "text/markdown" } + expect(response.body).to eq(baseline) + end + end + + context "browsers (Accept header includes text/html)" do + it "serves a rendered HTML page with the instructions content" do + get agent_instructions_path, headers: { "Accept" => browser_accept } + + expect(response).to have_http_status(:success) + expect(response.content_type).to include("text/html") + expect(response.body).to include("Connect your AI agent") + # The same markdown document, rendered — not served raw. + expect(response.body).to include("CoPlan API") + expect(response.body).to include("markdown-rendered") + end + + it "includes a copy-to-clipboard element carrying the raw instructions URL" do + get agent_instructions_path, headers: { "Accept" => browser_accept } + + expect(response.body).to include('data-controller="coplan--clipboard"') + expect(response.body).to include(%(data-coplan--clipboard-text-value="http://www.example.com/agent-instructions")) + end + + it "shows a curl example and links to the raw markdown" do + get agent_instructions_path, headers: { "Accept" => browser_accept } + + expect(response.body).to include("curl -s http://www.example.com/agent-instructions") + expect(response.body).to include("/agent-instructions.md") + end + + it "still serves raw markdown at .md even when the client accepts HTML" do + get agent_instructions_path(format: :md), headers: { "Accept" => browser_accept } + + expect(response.content_type).to include("text/markdown") + expect(response.body).to include("# CoPlan API") + expect(response.body).not_to include(" "*/*" } + + expect(response.content_type).to include("text/html") + expect(response.body).to include("Connect your AI agent") + end + + it "renders the signed-in nav chrome for signed-in users" do + user = create(:coplan_user, name: "Naveen Chrome") + sign_in_as(user) + + get agent_instructions_path, headers: { "Accept" => browser_accept } + + expect(response.body).to include("Naveen Chrome") + end + end + end end diff --git a/spec/requests/welcome_spec.rb b/spec/requests/welcome_spec.rb index 34922fac..2d09c2e1 100644 --- a/spec/requests/welcome_spec.rb +++ b/spec/requests/welcome_spec.rb @@ -5,6 +5,27 @@ let(:bob) { create(:coplan_user) } describe "GET /" do + context "anonymous visitor" do + it "renders the landing page without requiring sign-in" do + get root_path + expect(response).to have_http_status(:ok) + expect(response.body).to include("Design docs, built for AI-assisted planning") + expect(response.body).to include("How it works") + end + + it "shows a sign-in CTA instead of the plans CTA" do + get root_path + expect(response.body).to include("Sign in to get started") + expect(response.body).not_to include("Browse plans") + end + + it "points visitors at the agent instructions" do + get root_path + expect(response.body).to include("Connect your agent") + expect(response.body).to include("/agent-instructions") + end + end + context "signed-in user with no plans" do before { sign_in_as(bob) } @@ -117,6 +138,12 @@ expect(response.body).to include("Built for any AI agent") expect(response.body).to include("/agent-instructions") end + + it "includes a copy-URL element carrying the full instructions URL" do + get welcome_path + expect(response.body).to include('data-controller="coplan--clipboard"') + expect(response.body).to include(%(data-coplan--clipboard-text-value="http://www.example.com/agent-instructions")) + end end context "with a host-configured override" do