Skip to content

Fix comment form positioning and eliminate scroll-to-top#15

Merged
HamptonMakes merged 8 commits into
mainfrom
akwan/fix_comment_form_positioning
Feb 24, 2026
Merged

Fix comment form positioning and eliminate scroll-to-top#15
HamptonMakes merged 8 commits into
mainfrom
akwan/fix_comment_form_positioning

Conversation

@alicekykwan

@alicekykwan alicekykwan commented Feb 24, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Eliminate scroll-to-top on comment submission: Both comment controllers (CommentsController, CommentThreadsController) now respond with turbo_stream format instead of redirect_to, preventing full Turbo Drive page navigation and scroll reset
  • Position new comment form near selected text: The comment form is now absolutely positioned in the sidebar at the same vertical level as the text selection, instead of always appearing at the top of the sidebar
  • Fix anchor click disambiguation: scrollToAnchor now uses anchor_context to find the correct occurrence of highlighted text, not just the first match
  • Sort comment threads by document position: Threads in the sidebar are sorted by their anchor's vertical position in the plan content and DOM-reordered to match, so they appear in reading order
  • Clear duplicate highlights on re-highlight: Existing <mark> elements are unwrapped before re-running highlightAnchors() to prevent stacked/nested marks accumulating after each comment submission

What changed

Controllers:

  • CommentsController#create and CommentThreadsController#create return empty turbo_stream responses (WebSocket broadcast handles DOM updates)
  • Thread actions (resolve/accept/dismiss/reopen) use respond_with_stream_or_redirect helper for the same pattern
  • HTML fallback preserved for non-Turbo browsers

Stimulus (text_selection_controller.js):

  • openCommentForm positions form absolutely near the selection using popover coordinates
  • resetCommentForm / resetReplyForm callbacks on turbo:submit-end reset forms client-side
  • scrollToAnchor reads data-anchor-context for disambiguation
  • positionThreads sorts by anchor position, reorders DOM, positions both open and resolved lists independently
  • repositionThreads called on tab switch with delay for panel visibility
  • highlightAnchors clears existing marks before re-highlighting

Views:

  • Extracted _new_comment_form.html.erb and _reply_form.html.erb partials
  • Added id="resolved-comment-threads" to resolved thread list
  • Tab buttons trigger text-selection#repositionThreads on switch

Test plan

  • Select text in plan, click Comment — form appears next to selection (not at top)
  • Submit comment — page stays in place, form hides, new thread appears in sidebar
  • Reply to a thread — page stays in place, textarea clears
  • Resolve/Accept/Dismiss a thread — no scroll, thread updates in place
  • Click anchor quote in thread — scrolls to correct occurrence (not first)
  • Multiple anchored threads — sidebar order matches document order
  • Switch between Open/Resolved tabs — threads positioned correctly in both
  • Submit multiple comments — no duplicate/stacked highlights
Screen.Recording.2026-02-23.at.4.23.09.PM.mov

🤖 Generated with Claude Code

alicekykwan and others added 2 commits February 23, 2026 16:13
Before:
- Submitting a comment (new thread or reply) triggered a full Turbo Drive
  redirect to plan_path, which re-rendered the page with a flash notice at
  the top and scrolled the browser to the top of the page.
- The new comment form always appeared at the top of the sidebar regardless
  of where the user selected text in the plan content.
- Clicking an anchor quote in a comment thread scrolled to the first
  occurrence of that text in the plan, not the specific instance the user
  originally highlighted (no context disambiguation).
- Comment threads in the sidebar were ordered by DOM insertion order (newest
  first from broadcasts), not by their anchor's vertical position in the
  plan document.
- Switching between Open and Resolved tabs did not reposition threads next
  to their anchored text.

After:
- Comment and thread controllers respond with turbo_stream format (empty
  stream for the submitting browser, letting the existing WebSocket
  broadcast handle DOM updates) instead of redirecting. The page stays in
  place with no scroll and no flash banner.
- The new comment form is absolutely positioned in the sidebar at the same
  vertical level as the selected text, using the popover's position as
  reference. Focus uses preventScroll to avoid any scroll jump.
- After submission, the form hides and resets via a turbo:submit-end
  Stimulus callback, then re-runs highlightAnchors/positionThreads to
  pick up the newly broadcast thread.
- Reply forms reset their textarea via a separate turbo:submit-end callback.
- scrollToAnchor now reads data-anchor-context from the parent thread
  element and uses findAndHighlightWithContext for disambiguation, scrolling
  to the correct occurrence.
- positionThreads now sorts threads by their anchor's vertical position in
  the document and reorders the DOM to match, so threads appear in the
  sidebar in the same order as their highlighted text in the plan.
- Open and Resolved thread lists are positioned independently. Tab switches
  trigger repositionThreads with a short delay to allow the panel to become
  visible before measuring positions.

Extracted partials:
- comment_threads/_new_comment_form.html.erb (from plans/show.html.erb)
- comment_threads/_reply_form.html.erb (from comment_threads/_thread.html.erb)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
highlightAnchors() is now called after each comment submission (via
resetCommentForm) to pick up newly broadcast threads. Without clearing
existing <mark> elements first, each call wraps text in additional nested
marks, accumulating duplicate highlights over time.

Now unwraps existing mark.anchor-highlight elements and normalizes the
text nodes before re-running the highlight pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@HamptonMakes

Copy link
Copy Markdown
Collaborator

@codex mind taking a look?

respond_to do |format|
format.turbo_stream { render turbo_stream: [] }
format.html { redirect_to plan_path(@plan), notice: "Comment added." }
end

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

why not use the helper set up below?


redirect_to plan_path(@plan), notice: "Reply added."
respond_to do |format|
format.turbo_stream { render turbo_stream: [] }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

why empty turbo streams for these?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

added a comment for explanation. now there's no blue banner at the top. the comment appearing in the form seems confirmation enough

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b8be8c76cb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@thread.resolve!(current_user)
broadcast_thread_update(@thread)
redirect_to plan_path(@plan), notice: "Thread resolved."
respond_with_stream_or_redirect("Thread resolved.")

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 Return tab-aware turbo streams for status transitions

This path now returns an empty Turbo Stream response, but resolve/accept/dismiss/reopen still only broadcast replace for the existing thread DOM id, so the updated thread stays in its current list instead of moving between Open/Resolved tabs and the tab counts remain stale until a full reload. Before this change, the redirect to plans#show re-rendered both collections from server state, so Turbo users now get inconsistent thread state after status changes.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

updated


redirect_to plan_path(@plan), notice: "Reply added."
respond_to do |format|
format.turbo_stream { render turbo_stream: [] }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Include direct Turbo updates instead of empty responses

For Turbo requests this now renders an empty stream and relies entirely on ActionCable delivery for the initiating user to see their own reply; if the websocket is disconnected or delayed, the comment is persisted but no UI change appears because there is no redirect and no DOM operation in the HTTP response. Returning the append/prepend as part of the Turbo response would preserve correctness for the actor even when cable delivery is unavailable.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

is broadcast_append_to not sufficient? when would that fail but format.turbo_stream work?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Returning the append in both the HTTP response and the broadcast would cause duplicate comments in the UI for the submitter (Turbo doesn't deduplicate appends).

The WebSocket-down scenario is real but already degrades the entire real-time experience — other people's comments, thread moves, and tab count updates all rely on the same broadcast channel. If we want to harden against that, it's a broader concern (e.g., periodic polling fallback) rather than something to solve per-action.

alicekykwan and others added 2 commits February 24, 2026 09:39
…ent empty turbo_stream pattern

The create action in CommentThreadsController duplicated the respond_to
block that already existed as respond_with_stream_or_redirect. Also added
comments explaining why the turbo_stream responses return empty arrays:
broadcasts handle the UI updates for all clients, and the empty response
just prevents Turbo from navigating (which causes scroll-to-top).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ab counts

Previously, resolve/accept/dismiss/reopen broadcast a replace on the
thread's DOM id, leaving it in the wrong tab list with stale counts.
Now broadcasts remove the thread from its current list and append it
to the correct one, and tab counts are updated via targeted turbo
stream updates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@alicekykwan

Copy link
Copy Markdown
Contributor Author

@codex can you review again

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@alicekykwan

Copy link
Copy Markdown
Contributor Author

@codex I connected to github. can you review again

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3e6da74a26

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +95 to 99
Turbo::StreamsChannel.broadcast_append_to(
@plan,
target: dom_id(thread),
target: to,
partial: "comment_threads/thread",
locals: { thread: thread, plan: @plan }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Clear empty-state placeholder before appending moved thread

When a thread is moved between tabs, this code appends the rendered thread into the destination list but never removes the existing empty-state paragraph from the initial server render (No resolved comments. / No open comments. in plans/show). If the destination tab was empty, resolving/reopening the first thread leaves contradictory UI (a real thread plus the “No … comments.” message) until a full reload.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@codex addressed. can you write back on this comment thread if satisfactory

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The empty-state messages ("No open comments." / "No resolved comments.")
are now always present in the DOM (hidden when threads exist) and toggled
via turbo stream broadcasts alongside the tab count updates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@alicekykwan

Copy link
Copy Markdown
Contributor Author

@codex review.
can you reply on your previous comments if new commits addressed your previous concerns in a satisfactory manner?

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 32d7e9e041

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@thread.update!(status: "open", resolved_by_user: nil)
broadcast_thread_update(@thread)
redirect_to plan_path(@plan), notice: "Thread reopened."
broadcast_thread_move(@thread, from: "resolved-comment-threads", to: "comment-threads")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep reopened out-of-date threads in archived list

Don't unconditionally move reopened threads into comment-threads: CommentThread.active excludes out_of_date rows while archived includes them, so reopening a resolved thread that is already out-of-date will be broadcast into the Open tab even though server-side scopes still classify it as archived. In that case the tab counts/empty states are computed from scopes and no longer match what was rendered, until a full reload re-sorts it back.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

good catch. addressed.

Out-of-date threads remain in the archived scope even when reopened,
since active requires out_of_date: false. Now reopen does an in-place
replace instead of a cross-tab move when the thread is out of date.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@alicekykwan

Copy link
Copy Markdown
Contributor Author

@codex review holistically, not just the most recent commit?

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 33c14f8705

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

# The empty turbo_stream response prevents Turbo from navigating (which causes scroll-to-top).
def respond_with_stream_or_redirect(message)
respond_to do |format|
format.turbo_stream { render turbo_stream: [] }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve per-user thread actions for Turbo submitters

Rendering an empty Turbo Stream response here makes the submitting browser depend entirely on WebSocket broadcasts for thread card HTML, but those broadcasts render comment_threads/thread without current_user; in that partial, viewer.nil? is treated as plan-author permissions, so non-plan-authors immediately see controls like Accept/Dismiss/Reopen that they are not actually allowed to use. This is a regression in action visibility for any create/move update that now stays on-page.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is a pre-existing issue — the broadcasts have never passed current_user, even before this PR. Our changes didn't introduce the permissive viewer.nil? fallback; they just made it more visible by relying on broadcasts instead of full-page redirects. Fixing this properly (per-user turbo streams or a separate broadcast-safe partial) is worth doing but out of scope here.

broadcast_thread_update(@thread)
redirect_to plan_path(@plan), notice: "Thread resolved."
broadcast_thread_move(@thread, from: "comment-threads", to: "resolved-comment-threads")
respond_with_stream_or_redirect("Thread resolved.")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Recompute sidebar thread positions after in-place status updates

These status actions now avoid redirects, but there is no corresponding client-side reposition pass for the remaining open/resolved thread cards. Since cards are laid out with persisted inline marginTop values from positionThreads(), removing/moving one thread leaves siblings with stale offsets until a manual tab switch triggers repositionThreads, which causes anchor/thread misalignment in normal resolve/reopen flows.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed — added a MutationObserver on the thread lists that automatically re-runs highlightAnchors() (which includes positionThreads()) whenever threads are added or removed via turbo stream broadcasts. This also let us remove the manual setTimeout in resetCommentForm.

Added a MutationObserver on the thread lists that triggers
highlightAnchors() (which includes positionThreads()) whenever
threads are added or removed via turbo stream broadcasts. This
fixes stale marginTop offsets after resolve/reopen/dismiss actions.
The manual setTimeout in resetCommentForm is no longer needed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@alicekykwan

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2cb5c9cd2e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

})

// Reorder DOM within this list only
threads.forEach(thread => threadList.appendChild(thread))

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 Avoid self-triggered rehighlight loop when reordering threads

observeThreadLists watches each .comment-threads-list for childList mutations, and positionThreadList then unconditionally does appendChild on every thread during each highlight pass. With 2+ threads, those appends generate new childList mutations, which schedule another highlightAnchors() call, creating a continuous 50ms rehighlight/reposition cycle that can cause persistent CPU churn and UI jitter.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@codex address that feedback

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch — fixed. The MutationObserver is now disconnected before positionThreads reorders the DOM (via appendChild), and reconnected after. This prevents the self-triggering loop.

# The empty turbo_stream response prevents Turbo from navigating (which causes scroll-to-top).
def respond_with_stream_or_redirect(message)
respond_to do |format|
format.turbo_stream { render turbo_stream: [] }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Return Turbo Stream updates instead of an empty stream

This response path sends an empty Turbo Stream and relies entirely on WebSocket broadcasts for the initiating user to see the result. If the submitter’s ActionCable connection is temporarily down (reconnect window, blocked WS, etc.), the mutation succeeds server-side but the page shows no local update (and the form reset handlers still run), so users can lose immediate feedback until a manual refresh.

Useful? React with 👍 / 👎.

positionThreadList uses appendChild to reorder threads, which triggers
the MutationObserver and schedules another highlightAnchors() call,
creating an infinite 50ms loop. Now the observer is disconnected before
reordering and reconnected after.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@alicekykwan

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e8bc5e4d42

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

# The empty turbo_stream response prevents Turbo from navigating (which causes scroll-to-top).
def respond_with_stream_or_redirect(message)
respond_to do |format|
format.turbo_stream { render turbo_stream: [] }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Return a fallback stream for thread mutations

For Turbo requests this helper now returns an empty stream (render turbo_stream: []), so the initiating browser only updates if its turbo_stream_from @plan ActionCable subscription is healthy; when WebSocket delivery is blocked or briefly disconnected, resolve/accept/dismiss/reopen/create complete server-side but the page shows no change, which can lead users to retry and submit duplicate actions.

Useful? React with 👍 / 👎.

# The broadcast above updates all clients (including the submitter) via WebSocket.
# The empty turbo_stream response prevents Turbo from navigating (which causes scroll-to-top).
respond_to do |format|
format.turbo_stream { render turbo_stream: [] }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Return a fallback stream for reply submissions

The reply endpoint now also responds to Turbo with an empty stream body, which makes successful replies depend entirely on the ActionCable broadcast arriving; if the client is on Turbo Drive but unsubscribed/disconnected from the plan stream, the comment is persisted yet no comment appears in the UI, encouraging accidental re-submission.

Useful? React with 👍 / 👎.

@HamptonMakes
HamptonMakes merged commit 9796657 into main Feb 24, 2026
4 checks passed
@HamptonMakes
HamptonMakes deleted the akwan/fix_comment_form_positioning branch February 24, 2026 19:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants