Commit 07a55f2
authored
🤖 fix: preserve creation prompt caret on workspace updates (#1284)
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`
---
<details>
<summary>📋 Implementation Plan</summary>
# 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 `ProjectPage` → `ChatInput` (`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”.
2) **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
3) **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.
<details>
<summary>Alternative (more robust, more code): preserve selection across
temporary focus loss</summary>
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.
</details>
## 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.
</details>
---
_Generated with `mux` • Model: `openai:gpt-5.2` • Thinking: `xhigh`_
Signed-off-by: Thomas Kosiewski <[email protected]>1 parent 086aea2 commit 07a55f2
File tree
3 files changed
+106
-11
lines changed- src/browser/components
- ChatInput
3 files changed
+106
-11
lines changed| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
506 | 506 | | |
507 | 507 | | |
508 | 508 | | |
| 509 | + | |
| 510 | + | |
509 | 511 | | |
510 | 512 | | |
511 | | - | |
512 | | - | |
| 513 | + | |
| 514 | + | |
513 | 515 | | |
514 | 516 | | |
515 | 517 | | |
516 | 518 | | |
517 | 519 | | |
518 | 520 | | |
519 | 521 | | |
520 | | - | |
521 | | - | |
522 | | - | |
523 | | - | |
524 | | - | |
525 | | - | |
526 | | - | |
527 | | - | |
528 | | - | |
| 522 | + | |
529 | 523 | | |
530 | 524 | | |
531 | 525 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
53 | 53 | | |
54 | 54 | | |
55 | 55 | | |
| 56 | + | |
56 | 57 | | |
57 | 58 | | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
58 | 67 | | |
59 | 68 | | |
60 | 69 | | |
| |||
0 commit comments