diff --git a/engine/app/assets/stylesheets/coplan/application.css b/engine/app/assets/stylesheets/coplan/application.css index b8807c1..7366b20 100644 --- a/engine/app/assets/stylesheets/coplan/application.css +++ b/engine/app/assets/stylesheets/coplan/application.css @@ -2520,3 +2520,156 @@ body:not(:has(.comment-toolbar)) .web-push-banner { flex: 1; } } + +/* Landing page (rendered at "/" for users with no plans, and at "/welcome") */ +.landing { + max-width: 60rem; + margin: 0 auto; + padding: var(--space-2xl) var(--space-lg); +} + +.landing__hero { + text-align: center; + margin-bottom: var(--space-2xl); +} + +.landing__title { + font-size: 2.25rem; + font-weight: 700; + line-height: 1.2; + margin: 0 0 var(--space-md); + color: var(--color-text); +} + +.landing__lede { + font-size: var(--text-lg); + line-height: 1.6; + color: var(--color-text-muted); + max-width: 40rem; + margin: 0 auto var(--space-xl); +} + +.landing__cta-row { + display: flex; + justify-content: center; + gap: var(--space-md); + flex-wrap: wrap; +} + +.landing__section-title { + font-size: var(--text-xl); + font-weight: 600; + margin: 0 0 var(--space-lg); + color: var(--color-text); +} + +.landing__how { + margin-bottom: var(--space-2xl); +} + +.landing__steps { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); + gap: var(--space-lg); +} + +.landing__step { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); + padding: var(--space-lg); +} + +.landing__step h3 { + font-size: var(--text-lg); + font-weight: 600; + margin: var(--space-sm) 0; + color: var(--color-text); +} + +.landing__step p { + margin: 0; + font-size: var(--text-sm); + line-height: 1.6; + color: var(--color-text-muted); +} + +.landing__step-number { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: 50%; + background: var(--color-primary); + color: var(--color-text-inverse); + font-weight: 600; + font-size: var(--text-sm); +} + +.landing__agents { + background: var(--color-bg-muted); + border-radius: var(--radius); + padding: var(--space-xl); + margin-bottom: var(--space-xl); +} + +.landing__agents p { + margin: 0; + font-size: var(--text-base); + line-height: 1.6; + color: var(--color-text); +} + +.landing__inline-link { + color: var(--color-primary); + text-decoration: underline; + font-weight: 500; +} + +.landing__footer { + text-align: center; + padding-top: var(--space-lg); + border-top: 1px solid var(--color-border); + font-size: var(--text-sm); + color: var(--color-text-muted); +} + +@media (max-width: 640px) { + .landing { + padding: var(--space-xl) var(--space-md); + } + + .landing__title { + font-size: 1.75rem; + } +} + +/* Stale-content banner — shown when the plan body was updated in another tab + but the local tab has a dirty draft (textarea/contenteditable). User can't + safely auto-swap, so we warn them and offer a reload button. */ +.plan-stale-banner { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-md); + margin: 0 0 var(--space-md); + padding: var(--space-md) var(--space-lg); + background: var(--color-warning-soft); + border: 1px solid var(--color-warning); + border-radius: var(--radius); + font-size: var(--text-sm); + color: var(--color-text); + position: sticky; + top: var(--space-sm); + z-index: 5; +} + +.plan-stale-banner__message { + flex: 1; + line-height: 1.5; +} + +.plan-stale-banner__reload { + flex-shrink: 0; +} diff --git a/engine/app/controllers/coplan/api/v1/operations_controller.rb b/engine/app/controllers/coplan/api/v1/operations_controller.rb index a34aa87..acf9f48 100644 --- a/engine/app/controllers/coplan/api/v1/operations_controller.rb +++ b/engine/app/controllers/coplan/api/v1/operations_controller.rb @@ -329,6 +329,7 @@ def broadcast_plan_update partial: "coplan/plans/header", locals: { plan: @plan } ) + Broadcaster.replace_plan_content(@plan) end end end diff --git a/engine/app/controllers/coplan/dashboard_controller.rb b/engine/app/controllers/coplan/dashboard_controller.rb deleted file mode 100644 index 9e31c31..0000000 --- a/engine/app/controllers/coplan/dashboard_controller.rb +++ /dev/null @@ -1,6 +0,0 @@ -module CoPlan - class DashboardController < ApplicationController - def show - end - end -end diff --git a/engine/app/controllers/coplan/plans_controller.rb b/engine/app/controllers/coplan/plans_controller.rb index 54e02c5..3b56e14 100644 --- a/engine/app/controllers/coplan/plans_controller.rb +++ b/engine/app/controllers/coplan/plans_controller.rb @@ -124,6 +124,7 @@ def toggle_checkbox end broadcast_plan_update(@plan) + Broadcaster.replace_plan_content(@plan) render json: { revision: @plan.current_revision } rescue Plans::OperationError => e render json: { error: e.message }, status: :unprocessable_content diff --git a/engine/app/controllers/coplan/welcome_controller.rb b/engine/app/controllers/coplan/welcome_controller.rb new file mode 100644 index 0000000..462c570 --- /dev/null +++ b/engine/app/controllers/coplan/welcome_controller.rb @@ -0,0 +1,39 @@ +module CoPlan + # Renders the public landing page (mounted at "/welcome" and at "/"). + # + # Behavior at "/" (root): + # * Signed-in users who already have at least one plan are redirected to the + # plans index — they know what CoPlan is and don't need the intro. + # * Everyone else (signed-in users with no plans yet, or anyone hitting the + # page anonymously) sees the landing partial configured via + # `CoPlan.configuration.landing_page_partial`. + # + # Hosts can override the partial to inject deployment-specific copy (e.g. + # coplan-square renders a Square-flavored landing that mentions + # `sq agents skills add coplan`). + class WelcomeController < ApplicationController + # The landing page is intentionally public — it's the "what is this thing" + # page that needs to work for first-time visitors. We replace the engine's + # required-auth `before_action` with a softer version that resolves the + # current user when present (so we can personalize CTAs and redirect + # established users to /plans) but doesn't reject anonymous visitors. + # Hosts that gate the whole app at the perimeter (BeyondCorp, OIDC) will + # still enforce sign-in upstream. + skip_before_action :authenticate_coplan_user! + before_action :resolve_optional_coplan_user + + def show + if signed_in? && current_user.created_plans.exists? && params[:force].blank? + redirect_to plans_path and return + end + + @landing_partial = CoPlan.configuration.landing_page_partial + end + + private + + def resolve_optional_coplan_user + @current_coplan_user = CoPlan::Authentication.user_from_request(request) + end + end +end diff --git a/engine/app/javascript/controllers/coplan/live_update_controller.js b/engine/app/javascript/controllers/coplan/live_update_controller.js new file mode 100644 index 0000000..b2b4226 --- /dev/null +++ b/engine/app/javascript/controllers/coplan/live_update_controller.js @@ -0,0 +1,124 @@ +import { Controller } from "@hotwired/stimulus" + +/* + * coplan--live-update + * + * Listens for the custom + * payloads broadcast by Broadcaster#replace_plan_content. When an agent + * (or anyone) commits a new revision elsewhere, the server pushes the new + * rendered body to every open tab. This controller decides what to do: + * + * * If the user has no unsaved drafts → swap the body in place. Existing + * Stimulus controllers reconnect over the new DOM, comment highlights + * re-attach. + * + * * If the user is mid-edit (any textarea on the page has non-empty, + * non-trim-blank text) → DON'T blow away their typing. Instead, show + * a sticky banner above the content: "This plan was updated to + * revision N. Reload to see the latest." with a button that reloads. + * + * The custom Turbo Stream action is registered exactly once per page — + * we use a window-level flag so multiple live-update controllers (one per + * plan body) don't fight each other. + */ +export default class extends Controller { + static values = { + revision: Number + } + + connect() { + this.constructor.registerStreamAction() + } + + static registerStreamAction() { + if (typeof window === "undefined") return + if (window.__coplanLiveUpdateRegistered) return + if (typeof window.Turbo === "undefined" || !window.Turbo.StreamActions) { + // Turbo not ready yet — try again once it loads. + document.addEventListener("turbo:load", () => this.registerStreamAction(), { once: true }) + return + } + + window.Turbo.StreamActions["coplan-replace-if-clean"] = function () { + // `this` is the element. Standard Turbo API. + const targetId = this.getAttribute("target") + const incomingRevision = parseInt(this.getAttribute("data-revision"), 10) || null + const target = document.getElementById(targetId) + if (!target) return + + // If the local DOM is already at this revision (or newer), skip — this + // tab is the one that issued the edit, no need to re-render. + const currentRevision = parseInt(target.getAttribute("data-coplan--live-update-revision-value"), 10) || 0 + if (incomingRevision && currentRevision >= incomingRevision) return + + // `templateContent` is a DocumentFragment — it has no `innerHTML`. + // Use replaceChildren(fragment) to swap the contents of target in one + // shot. Stimulus controllers inside target will disconnect + reconnect. + const fragment = this.templateContent + + if (hasDirtyDrafts()) { + showStaleBanner(target, incomingRevision) + } else { + target.replaceChildren(fragment) + if (incomingRevision) { + target.setAttribute("data-coplan--live-update-revision-value", String(incomingRevision)) + } + clearStaleBanner() + } + } + + window.__coplanLiveUpdateRegistered = true + } +} + +/* + * Returns true if ANY textarea or contenteditable on the page contains + * user-typed text. Used to decide whether it's safe to blow away the + * rendered body. We're conservative: if even one textarea has trimmed + * non-empty text, we treat the page as dirty. + */ +function hasDirtyDrafts() { + const textareas = document.querySelectorAll("textarea") + for (const ta of textareas) { + if (ta.value && ta.value.trim().length > 0) return true + } + const editables = document.querySelectorAll("[contenteditable='true']") + for (const el of editables) { + if (el.textContent && el.textContent.trim().length > 0) return true + } + return false +} + +function showStaleBanner(targetEl, revision) { + let banner = document.getElementById("plan-stale-banner") + if (banner) { + // Already showing — just bump the revision number. + const span = banner.querySelector("[data-revision]") + if (span && revision) span.textContent = String(revision) + return + } + + banner = document.createElement("div") + banner.id = "plan-stale-banner" + banner.className = "plan-stale-banner" + banner.setAttribute("role", "status") + banner.setAttribute("aria-live", "polite") + banner.innerHTML = ` +
+ ⚠️ This plan was updated${revision ? ` (now at revision ${revision})` : ""}. + Your draft is preserved here — reload to see the latest version. +
+ + ` + banner.querySelector(".plan-stale-banner__reload").addEventListener("click", () => { + window.location.reload() + }) + + // Insert directly above the stale content so the connection is visually obvious. + targetEl.parentNode.insertBefore(banner, targetEl) +} + +function clearStaleBanner() { + const banner = document.getElementById("plan-stale-banner") + if (banner) banner.remove() +} diff --git a/engine/app/services/coplan/broadcaster.rb b/engine/app/services/coplan/broadcaster.rb index 471e0b0..9c53cfb 100644 --- a/engine/app/services/coplan/broadcaster.rb +++ b/engine/app/services/coplan/broadcaster.rb @@ -22,6 +22,35 @@ def remove_to(streamable, target:) Turbo::StreamsChannel.broadcast_remove_to(streamable, target: target) end + # Broadcasts a custom turbo-stream action that the client may apply + # conditionally. Used by live-content-update: the client checks for + # unsaved drafts before swapping the body, otherwise shows a "reload" + # banner so the user doesn't lose typed-but-unsent text. + # + # We don't go through Turbo::StreamsChannel's helpers because they + # only emit the built-in actions (replace/update/append/etc.); a custom + # action requires building the element ourselves. + def custom_action_to(streamable, action:, target:, html:, attrs: {}) + attr_string = attrs.map { |k, v| %( #{k}="#{ERB::Util.html_escape(v)}") }.join + stream = %() + Turbo::StreamsChannel.broadcast_stream_to(streamable, content: stream.html_safe) + end + + # Convenience wrapper for the most common content-mutation broadcast: + # push the freshly rendered plan body to every open tab, letting the + # client decide whether to apply it (clean) or show a stale-revision + # banner (dirty draft in progress). + def replace_plan_content(plan) + html = render(partial: "coplan/plans/content_body", locals: { plan: plan }) + custom_action_to( + plan, + action: "coplan-replace-if-clean", + target: "plan-content-body", + html: html, + attrs: { "data-revision" => plan.current_revision } + ) + end + private def render(partial:, locals:) diff --git a/engine/app/services/coplan/plans/commit_session.rb b/engine/app/services/coplan/plans/commit_session.rb index 184c5a9..9cd94e6 100644 --- a/engine/app/services/coplan/plans/commit_session.rb +++ b/engine/app/services/coplan/plans/commit_session.rb @@ -144,6 +144,7 @@ def call partial: "coplan/plans/header", locals: { plan: plan } ) + Broadcaster.replace_plan_content(plan) { session: @session, version: version } end diff --git a/engine/app/services/coplan/plans/replace_content.rb b/engine/app/services/coplan/plans/replace_content.rb index 040c668..07f17cb 100644 --- a/engine/app/services/coplan/plans/replace_content.rb +++ b/engine/app/services/coplan/plans/replace_content.rb @@ -121,6 +121,7 @@ def call partial: "coplan/plans/header", locals: { plan: @plan } ) + Broadcaster.replace_plan_content(@plan) { version: version, plan: @plan, applied: result[:applied].length, no_op: false } end diff --git a/engine/app/views/coplan/dashboard/show.html.erb b/engine/app/views/coplan/dashboard/show.html.erb deleted file mode 100644 index 7c5a73f..0000000 --- a/engine/app/views/coplan/dashboard/show.html.erb +++ /dev/null @@ -1,8 +0,0 @@ - - -
-

Welcome to CoPlan.

-

Plans and collaboration tools will appear here once set up.

-
diff --git a/engine/app/views/coplan/plans/_content_body.html.erb b/engine/app/views/coplan/plans/_content_body.html.erb new file mode 100644 index 0000000..50cd0db --- /dev/null +++ b/engine/app/views/coplan/plans/_content_body.html.erb @@ -0,0 +1,4 @@ +<%# Rendered plan markdown. Lives inside #plan-content-body so it can be + swapped wholesale by live-update broadcasts when an agent (or anyone) + commits a new revision in another tab. -%> +<%= render_markdown(plan.current_content) %> diff --git a/engine/app/views/coplan/plans/show.html.erb b/engine/app/views/coplan/plans/show.html.erb index 9c7e2f7..5362933 100644 --- a/engine/app/views/coplan/plans/show.html.erb +++ b/engine/app/views/coplan/plans/show.html.erb @@ -36,7 +36,11 @@
- <%= render_markdown(@plan.current_content) %> +
+ <%= render partial: "coplan/plans/content_body", locals: { plan: @plan } %> +