Skip to content

Conversation

@ThomasK33
Copy link
Member

Fixes an annoying UX bug on the ProjectPage (workspace creation screen): when the workspace tree updates (e.g. a new workspace/subworkspace is created elsewhere), we were re-running ChatInput’s onReady wiring and re-focusing the textarea, which forces the caret to the end.

Changes:

  • ChatInput: stop re-running onReady effect on unrelated prop changes.
  • ProjectPage: only auto-focus the creation prompt once per mount (defensive against future regressions).
  • Added a regression test covering the ProjectPage autofocus behavior.

Validation:

  • make static-check

📋 Implementation Plan

Fix: prompt textarea caret jumps to end when other workspaces appear

What’s happening (and why it feels like the cursor “jumps to the bottom”)

While you’re typing in the workspace creation prompt (the screen in your screenshot), the app re-renders when any workspace/subworkspace is created elsewhere. That re-render unintentionally re-runs the “ready” callback for the chat input, which re-focuses the textarea and forcibly moves the caret to the end.

Concrete chain (with code pointers):

  • The screen is ProjectPageChatInput (variant="creation") → VimTextArea.
    • src/browser/components/ProjectPage.tsx
    • src/browser/components/ChatInput/index.tsx
  • ChatInput exposes an API to parents via an onReady effect.
    • Today that effect depends on props (the entire props object), so it re-runs whenever any prop identity changes.
    • src/browser/components/ChatInput/index.tsx (useEffect “Provide API to parent via callback”)
  • App.tsx passes an inline onWorkspaceCreated={(metadata) => { … }} function into ProjectPage, so every App re-render creates a new function identity.
    • When a workspace/subworkspace is created elsewhere, workspaceMetadata updates → App re-renders → new onWorkspaceCreated prop identity.
    • src/browser/App.tsx (rendering <ProjectPage … onWorkspaceCreated={(metadata) => { … }} />)
  • Because ChatInput’s onReady effect re-runs, ProjectPage.handleChatReady runs again and calls api.focus().
    • src/browser/components/ProjectPage.tsx (handleChatReady)
  • ChatInput.focusMessageInput() always does:
    • element.focus()
    • then selectionStart/selectionEnd = element.value.length
    • so your caret moves to the end and the textarea scrolls to follow it.
    • src/browser/components/ChatInput/index.tsx (focusMessageInput)

Recommended fix (Approach A — minimal, targeted)

Net product LoC estimate: ~+10 / -2

  1. Stop re-running onReady on unrelated re-renders
  • In src/browser/components/ChatInput/index.tsx, update the useEffect that calls props.onReady(…).
  • Remove props from the dependency array; depend only on the specific values used (props.onReady, and the API callbacks).
  • This makes onReady behave like “component mounted / API changed” instead of “any parent re-render”.
  1. Make ProjectPage’s initial autofocus idempotent
  • In src/browser/components/ProjectPage.tsx, guard handleChatReady so it only calls api.focus() once per mount.
    • e.g. const didAutoFocusRef = useRef(false); and only focus when false.
    • (Alternative guard) only focus if document.activeElement is not already the textarea / not inside the chat input.
  • This is defensive: even if onReady is called again for some legitimate reason in the future, we won’t steal the caret.

Optional hardening (Approach B — improve focus behavior)

Net product LoC estimate: ~+10–20

  1. Don’t force caret to end if the textarea is already focused
  • In focusMessageInput (src/browser/components/ChatInput/index.tsx), detect when document.activeElement === inputRef.current.
  • If it’s already focused, skip the selectionStart/selectionEnd = value.length step.

Why this is useful:

  • It prevents any future “spurious focus” call (from keybinds, popovers, or re-renders) from hijacking the user’s selection.
Alternative (more robust, more code): preserve selection across temporary focus loss

If you want the caret to return to the exact prior position even after focus moves away (e.g. opening a model picker), store selectionStart/End in a ref via onSelect/onKeyUp, then restore it on re-focus. This is more invasive and should be done only if Approach A+B still leaves edge cases.

Tests / regression coverage

Net product LoC estimate: 0
Net test LoC estimate: ~+40–80

Add a regression test that fails with today’s behavior:

  • Render ChatInput in variant="creation" (or render ProjectPage if that’s easier for setup).
  • Type a multi-line value, then place the caret in the middle (setSelectionRange).
  • Trigger a parent re-render that changes a prop identity (e.g., provide a new onWorkspaceCreated function via rerender).
  • Assert that selectionStart/End did not change.

Good locations:

  • New: src/browser/components/ChatInput/ChatInputCaret.test.tsx
  • Or extend existing creation-related tests under src/browser/components/ChatInput/.

Manual QA checklist

  • On the ProjectPage (creation screen), type a multi-line prompt.
  • Move caret near the top.
  • Cause another workspace/subworkspace to be created (e.g. from another workspace).
  • Verify caret stays where you left it (no jump to end).
  • Verify initial autofocus on first entering ProjectPage still works.
  • Verify “intentional” focus paths still work (e.g. switching workspaces in sidebar should still focus input).

Notes / non-goals

  • This plan intentionally avoids larger architectural changes (like lifting ChatInput to a stable position in App.tsx). Those would also solve remount/selection issues but are higher-risk and higher-LoC.

Generated with mux • Model: openai:gpt-5.2 • Thinking: xhigh

Change-Id: Ib9c7e0869201b7d9aca6158a9432c21fef5598f0
Signed-off-by: Thomas Kosiewski <tk@coder.com>
@ThomasK33 ThomasK33 added this pull request to the merge queue Dec 22, 2025
Merged via the queue into main with commit 07a55f2 Dec 22, 2025
20 checks passed
@ThomasK33 ThomasK33 deleted the text-input-tzhc branch December 22, 2025 11:18
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