Skip to content

fix(editor): open saved drafts at the top instead of scrolling to the bottom - #3337

Merged
feruzm merged 4 commits into
developmentfrom
fix/editor-draft-open-top
Jul 12, 2026
Merged

fix(editor): open saved drafts at the top instead of scrolling to the bottom#3337
feruzm merged 4 commits into
developmentfrom
fix/editor-draft-open-top

Conversation

@feruzm

@feruzm feruzm commented Jul 12, 2026

Copy link
Copy Markdown
Member

Problem

Opening a saved draft scrolled the editor to the bottom of the body. When a draft had no locally-saved cursor position (legacy drafts, drafts from another device or web, or the first open after caret-resume shipped), the restore logic placed the caret at the end of the body; the multiline input auto-focuses, and focus with the cursor at the end scrolls a long draft to the bottom.

This was a regression: caret-resume originally fell back to the top; a later follow-up flipped the missing-caret fallback to the end (to avoid prepend-on-type), which reintroduced the bottom-scroll.

Fix

  • New pure, unit-tested helper resolveRestoreCaret(savedCaret, bodyLength, isReply):
    • no saved caret -> top (0) for posts/drafts; end for replies (append a cached comment, not prepend)
    • saved caret -> resume there, clamped to the current body length
  • Restoring a post/draft with no saved caret leaves the body unfocused (blur + skip the delayed auto-focus), so it opens at the top with no active cursor to prepend into. Replies and empty new-compose keep auto-focus.
  • Ignore the native echo of a programmatically-set selection so it is not persisted as a user caret move (which would make position 0 sticky).

Behavior

  • Saved draft -> opens at the top, unfocused; tap to place the caret and continue.
  • Draft edited on this device -> resumes at the last cursor position, focused.
  • Reply / new-empty compose -> unchanged (auto-focus).
  • Inserting image/link/snippet while editing -> unchanged; inserts at the cursor and keeps the caret after the inserted content.

Tests

New src/utils/editorCaret.test.ts covers the caret fallback matrix. Full jest suite passes; lint clean.

Summary by CodeRabbit

  • Bug Fixes

    • Improved caret and selection restoration for posts/drafts and replies, including correct fallback behavior when no saved caret exists.
    • Prevented caret persistence from being overwritten during programmatic restore flows.
    • Restored caret positions are now clamped to the current text length to avoid out-of-range placement.
  • Editor Experience

    • Posts/drafts without a saved caret no longer trigger an extra focus/caret moment at load.
    • Replies continue to open with focus and the cursor positioned at the end.
  • Tests

    • Added coverage for caret restoration scenarios, including edge cases and clamping.

… bottom

When a draft had no locally-saved cursor position (legacy drafts, drafts
created on another device or on web, or the first open after caret-resume
shipped), the restore logic placed the caret at the end of the body. The
multiline body input auto-focuses, and focusing with the cursor at the end
scrolls a long draft to the bottom.

- Extract the restore-caret decision into a pure, unit-tested helper
  resolveRestoreCaret(savedCaret, bodyLength, isReply): no saved caret falls
  back to the top (0) for posts/drafts and to the end for replies (so a cached
  comment is appended to, not prepended); a saved caret resumes there, clamped
  to the current body length.
- When restoring a post/draft with no saved caret, leave the body unfocused
  (blur + skip the delayed auto-focus) so the view stays at the top and there
  is no active cursor to prepend into. Replies and empty new-compose keep
  auto-focus.
- Ignore the native echo of a programmatically-set selection so it is not
  persisted as a user caret move (which would make position 0 sticky).
@greptile-apps

greptile-apps Bot commented Jul 12, 2026

Copy link
Copy Markdown

Greptile Summary

This PR updates markdown draft caret restore behavior so saved drafts open in the intended position.

  • Adds a pure helper for resolving restore caret positions.
  • Restores non-reply drafts without saved carets at the top.
  • Keeps replies opening at the end when no caret was saved.
  • Adds tests for caret fallback and clamping behavior.

Confidence Score: 4/5

This is close, but the restore echo path should be fixed before merging.

  • The caret fallback helper is small and covered by focused tests.
  • The delayed focus logic avoids the stale suppression flag pattern.
  • A native selection echo during restore can still save caret 0 and bring back the prepend-on-type workflow.

src/components/markdownEditor/view/markdownEditorView.tsx

Important Files Changed

Filename Overview
src/components/markdownEditor/view/markdownEditorView.tsx Updates draft restore, focus, and selection persistence behavior; the restore selection echo can still persist caret 0.
src/utils/editorCaret.ts Adds the caret restore helper for post and reply fallback behavior.
src/utils/editorCaret.test.ts Adds unit tests for caret fallback, saved caret handling, and clamping.

Fix All in Claude Code

Reviews (4): Last reviewed commit: "fix(editor): clear delayed autofocus tim..." | Re-trigger Greptile

Comment thread src/components/markdownEditor/view/markdownEditorView.tsx Outdated
Comment thread src/components/markdownEditor/view/markdownEditorView.tsx Outdated

@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: 2772a97ab0

ℹ️ 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 +175 to +177
if (!hasSavedCaret && !isReply) {
suppressBodyAutoFocusRef.current = true;
inputRef.current?.blur();

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 Cancel pending caret writes when suppressing focus

When a no-caret post draft is loaded after the empty editor has already emitted an onSelectionChange (for example from initial focus before tapping DRAFT), _persistCaret(0) can already be queued. This branch skips the restore echo but never cancels that pending debounced write, so it still stores caret 0; because resolveRestoreCaret now treats 0 as a saved caret, the next open will focus at the top and reintroduce prepend-on-type. Please clear the pending caret debounce before suppressing focus.

Useful? React with 👍 / 👎.

@coderabbitai

coderabbitai Bot commented Jul 12, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 26d9741e-3dc8-4327-8510-c11508cf3420

📥 Commits

Reviewing files that changed from the base of the PR and between a871e48 and bb13c6d.

📒 Files selected for processing (1)
  • src/components/markdownEditor/view/markdownEditorView.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/components/markdownEditor/view/markdownEditorView.tsx

📝 Walkthrough

Walkthrough

The Markdown editor now uses a shared caret-restoration helper, suppresses autofocus when loading posts or drafts without saved carets, cancels queued persistence after restoration, and persists selection changes only while the input is focused.

Changes

Caret management

Layer / File(s) Summary
Caret restoration helper
src/utils/editorCaret.ts, src/utils/editorCaret.test.ts
resolveRestoreCaret restores saved positions, clamps stale offsets, applies reply-specific fallbacks, distinguishes saved position 0, and reports whether a saved caret exists.
Restore, focus, and persistence integration
src/components/markdownEditor/view/markdownEditorView.tsx
The editor uses the helper, cancels queued caret writes after restoration, suppresses autofocus for posts and drafts without saved carets, and persists selection changes only while focused.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

Poem

A bunny taps the editor bright,
Restoring paws to places right.
Saved hops stay, echoes fade,
Replies leap to the end they made.
No caret? We pause, then hop away! 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main user-facing fix: saved drafts now open at the top instead of the bottom.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/editor-draft-open-top

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Address PR review feedback:
- Cancel any caret write queued by the empty input's initial focus when the
  draft body is restored, so a stale caret 0 can't be persisted under the draft
  key. On a no-caret draft that would resurface prepend-on-type on the next
  open; on a saved-caret draft it would clobber the position being resumed.
- Assign suppressBodyAutoFocusRef unconditionally so a saved-caret restore in
  the same mounted view clears a previously-armed suppression instead of
  leaving it stale and skipping a legitimate autofocus.
- Consume the programmatic-selection guard on the next selection event
  regardless of match, so a dropped or coalesced native echo can't leave it
  armed and later swallow a genuine user caret at the same offset.
Comment thread src/components/markdownEditor/view/markdownEditorView.tsx Outdated
Comment thread src/components/markdownEditor/view/markdownEditorView.tsx Outdated
…t flags

Replace the two refs raised in PR review with stateless checks that cannot go
stale:
- Drop suppressBodyAutoFocusRef: the delayed focus effect now re-derives, at fire
  time, whether we restored a non-reply body that still has no saved caret (an
  empty compose or a since-edited draft both focus normally). A stale flag can no
  longer skip a later legitimate autofocus in the same mounted editor.
- Drop lastProgrammaticSelectionRef: persist the caret only while the input is
  focused. A user caret move requires focus, and the no-caret restore blurs, so
  its async native selection echo is ignored without value-matching an echo
  (removing the dropped/delayed-echo edge cases). Programmatic sets while focused
  (inserts, saved-caret resume) echo the position we intend to keep anyway.
Comment thread src/components/markdownEditor/view/markdownEditorView.tsx

@coderabbitai coderabbitai 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.

🧹 Nitpick comments (1)
src/components/markdownEditor/view/markdownEditorView.tsx (1)

210-229: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Consider clearing the focus timeout on unmount or re-run.

The setTimeout at line 213 has no cleanup. If the component unmounts within the 1-second delay, the callback may call inputRef.current?.focus() on a torn-down instance. Similarly, if autoFocusText changes and the effect re-runs, the previous timeout is not cleared.

This is pre-existing, but the effect body is modified in this PR. Adding a cleanup is low-cost and prevents a potential post-unmount native command.

♻️ Optional cleanup
   useEffect(() => {
     if (isReply || (autoFocusText && inputRef && inputRef.current && draftBtnTooltipRegistered)) {
       // added delay to open keyboard, solves the issue of keyboard not opening
-      setTimeout(() => {
+      const timer = setTimeout(() => {
         // Skip focusing when we restored an existing non-reply body that still has no
         // saved caret: focusing would drop the cursor at the top (prepend-on-type) and
         // slide the keyboard over the draft. Re-derived here at fire time (not a stored
         // flag) so it can't go stale across draft/compose changes in the same mounted
         // editor: an empty compose (no body) or a draft the user has since edited (a
         // caret now exists) both focus normally; replies always focus.
         const restoredWithoutCaret =
           !isReply &&
           bodyTextRef.current !== '' &&
           typeof store.getState().editor.caretMap?.[caretKeyRef.current] !== 'number';
         if (!restoredWithoutCaret) {
           inputRef?.current?.focus();
         }
       }, 1000);
+      return () => clearTimeout(timer);
     }
   }, [autoFocusText]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/markdownEditor/view/markdownEditorView.tsx` around lines 210 -
229, Update the useEffect handling autoFocusText to retain the setTimeout handle
and return cleanup that clears it when the effect re-runs or the component
unmounts. Preserve the existing delayed focus and restoredWithoutCaret logic,
ensuring cleared timers cannot invoke inputRef.current.focus().
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/components/markdownEditor/view/markdownEditorView.tsx`:
- Around line 210-229: Update the useEffect handling autoFocusText to retain the
setTimeout handle and return cleanup that clears it when the effect re-runs or
the component unmounts. Preserve the existing delayed focus and
restoredWithoutCaret logic, ensuring cleared timers cannot invoke
inputRef.current.focus().

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b30de2f7-8b41-4c12-bb58-f00de0a78b8e

📥 Commits

Reviewing files that changed from the base of the PR and between 8429332 and a871e48.

📒 Files selected for processing (1)
  • src/components/markdownEditor/view/markdownEditorView.tsx

The 1s keyboard-focus timeout had no cleanup, so it could fire focus() on a
torn-down input or leak a stale timer when autoFocusText changed. Return a
clearTimeout cleanup from the effect.
Comment on lines +304 to +306
if (inputRef.current?.isFocused?.()) {
_persistCaret(selection.start);
}

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 Restore Echo Persists

The restore path still allows the programmatic selection at position 0 to be saved as a real caret. For a non-reply draft with no saved caret, _setTextAndSelection sets the selection before blur() runs. If the native selection echo reaches _handleOnSelectionChange while the input still reports focused, this branch queues _persistCaret(0) after the earlier cancel(). That makes 0 look like a saved caret on the next open, so the draft can focus at the top and the next typed character prepends into the body.

Fix in Claude Code

@feruzm
feruzm merged commit 627f452 into development Jul 12, 2026
11 of 13 checks passed
@feruzm
feruzm deleted the fix/editor-draft-open-top branch July 12, 2026 20:21
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.

1 participant