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: +//
+ 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. +
+ ++ 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. +
+<%= url %>
+
+- 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 %>Humans comment. AI agents edit.
- 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.
- 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.
- 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.
- 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. +
+ + + + ++ 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. +
++ 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. +
++ Comments, edits, status changes, and new versions broadcast instantly to everyone + viewing the plan — no refresh needed. +
+
+ 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.
+
+ 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. +
+
+ 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.
Tight-loop retries will hammer the processor. Exponential backoff with a cap?
+