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
153 changes: 153 additions & 0 deletions engine/app/assets/stylesheets/coplan/application.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,7 @@ def broadcast_plan_update
partial: "coplan/plans/header",
locals: { plan: @plan }
)
Broadcaster.replace_plan_content(@plan)
end
end
end
Expand Down
6 changes: 0 additions & 6 deletions engine/app/controllers/coplan/dashboard_controller.rb

This file was deleted.

1 change: 1 addition & 0 deletions engine/app/controllers/coplan/plans_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions engine/app/controllers/coplan/welcome_controller.rb
Original file line number Diff line number Diff line change
@@ -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
124 changes: 124 additions & 0 deletions engine/app/javascript/controllers/coplan/live_update_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { Controller } from "@hotwired/stimulus"

/*
* coplan--live-update
*
* Listens for the custom <turbo-stream action="coplan-replace-if-clean">
* 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 <turbo-stream> 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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Re-run anchor highlighting after live body replacement

Replacing #plan-content-body with target.replaceChildren(fragment) removes all existing <mark class="anchor-highlight..."> nodes, but this path never triggers text_selection_controller#highlightAnchors again. That controller only re-highlights on connect and when #plan-threads mutates, so after a clean live update the inline comment highlights (and dependent margin-dot/navigation state) disappear until a reload or thread mutation, breaking the core review UX for open plan tabs.

Useful? React with 👍 / 👎.

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 = `
<div class="plan-stale-banner__message">
⚠️ This plan was updated${revision ? ` (now at revision <strong data-revision>${revision}</strong>)` : ""}.
Your draft is preserved here — reload to see the latest version.
</div>
<button type="button" class="btn btn--primary btn--sm plan-stale-banner__reload">Reload</button>
`
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()
}
29 changes: 29 additions & 0 deletions engine/app/services/coplan/broadcaster.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 <turbo-stream> 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-stream action="#{action}" target="#{target}"#{attr_string}><template>#{html}</template></turbo-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:)
Expand Down
1 change: 1 addition & 0 deletions engine/app/services/coplan/plans/commit_session.rb
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ def call
partial: "coplan/plans/header",
locals: { plan: plan }
)
Broadcaster.replace_plan_content(plan)

{ session: @session, version: version }
end
Expand Down
1 change: 1 addition & 0 deletions engine/app/services/coplan/plans/replace_content.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading