-
Notifications
You must be signed in to change notification settings - Fork 1
Public landing page and live plan content updates (CIRCLE-49, CIRCLE-50) #113
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
HamptonMakes
merged 2 commits into
main
from
hampton/circle-49-50/landing-and-live-updates
May 21, 2026
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file was deleted.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
124
engine/app/javascript/controllers/coplan/live_update_controller.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| 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() | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Replacing
#plan-content-bodywithtarget.replaceChildren(fragment)removes all existing<mark class="anchor-highlight...">nodes, but this path never triggerstext_selection_controller#highlightAnchorsagain. That controller only re-highlights on connect and when#plan-threadsmutates, 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 👍 / 👎.