fix(sidebar): stop hover title cursor flicker at the source

The hovercard rename field auto-selected its whole title on open. On macOS a
pointer parked over a fresh selection flips between the I-beam and the native
drag-selection arrow — the "cursor keeps changing" flicker. The prior fix tried
to mask it with a global `cursor: text !important` style injection, which cannot
override that OS-level drag cursor.

Fix it structurally instead: the field editor opens with the caret at the end
(no selection), so there is nothing to drag and the cursor stays a stable
I-beam. Delete the entire cursor-lock subsystem — no other inline rename in the
app needs one. The inline sidebar row keeps its select-all one-keystroke replace.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-07-02 22:41:40 -07:00
co-authored by Orca
parent b7cb87613f
commit 289f2cb4bc
2 changed files with 13 additions and 107 deletions
@@ -26,8 +26,6 @@ describe('WorktreeTitleInlineRename lifecycle', () => {
root = null
container?.remove()
container = null
document.documentElement.removeAttribute('data-worktree-title-rename-cursor-lock')
document.getElementById('worktree-title-rename-cursor-lock-style')?.remove()
})
it('reports editing=false if it unmounts while renaming', () => {
@@ -117,7 +115,8 @@ describe('WorktreeTitleInlineRename lifecycle', () => {
setSelectionRange.mockRestore()
})
it('selects the full title when hovercard field mode starts editing', () => {
it('places the caret at the end when hovercard field mode starts editing', () => {
const displayName = 'Feature workspace'
const select = vi.spyOn(HTMLInputElement.prototype, 'select')
const setSelectionRange = vi.spyOn(HTMLInputElement.prototype, 'setSelectionRange')
const nextContainer = document.createElement('div')
@@ -127,7 +126,7 @@ describe('WorktreeTitleInlineRename lifecycle', () => {
act(() => {
root?.render(
<WorktreeTitleInlineRename
displayName="Feature workspace"
displayName={displayName}
editingPresentation="field"
onRename={vi.fn()}
/>
@@ -141,8 +140,11 @@ describe('WorktreeTitleInlineRename lifecycle', () => {
})
const input = nextContainer.querySelector('[data-worktree-title-rename-input]')
expect(select).toHaveBeenCalledTimes(1)
expect(setSelectionRange).not.toHaveBeenCalled()
// Why: the hovercard field opens with the caret at the end (no selection) so a
// pointer parked over the title never flips between the I-beam and the macOS
// drag-selection arrow.
expect(select).not.toHaveBeenCalled()
expect(setSelectionRange).toHaveBeenCalledWith(displayName.length, displayName.length)
expect(input?.className).toContain('select-text')
expect(input?.className).not.toContain('select-none')
@@ -150,52 +152,6 @@ describe('WorktreeTitleInlineRename lifecycle', () => {
setSelectionRange.mockRestore()
})
it('locks the document cursor only while the hovercard field editor is active', () => {
const nextContainer = document.createElement('div')
container = nextContainer
root = createRoot(nextContainer)
act(() => {
root?.render(
<WorktreeTitleInlineRename
displayName="Feature workspace"
editingPresentation="field"
onRename={vi.fn()}
/>
)
})
expect(document.documentElement.hasAttribute('data-worktree-title-rename-cursor-lock')).toBe(
false
)
act(() => {
nextContainer
.querySelector('[data-worktree-title-inline-rename]')
?.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true }))
})
expect(document.documentElement.hasAttribute('data-worktree-title-rename-cursor-lock')).toBe(
true
)
expect(
document.getElementById('worktree-title-rename-cursor-lock-style')?.textContent
).toContain('cursor: text !important')
act(() => {
nextContainer
.querySelector('[data-worktree-title-rename-input]')
?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })
)
})
expect(document.documentElement.hasAttribute('data-worktree-title-rename-cursor-lock')).toBe(
false
)
expect(document.getElementById('worktree-title-rename-cursor-lock-style')).toBeNull()
})
it('renders wrapped read text before mounting the hovercard field input', () => {
const nextContainer = document.createElement('div')
container = nextContainer
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { LoaderCircle } from 'lucide-react'
import { toast } from 'sonner'
import { Input } from '@/components/ui/input'
@@ -25,50 +25,6 @@ export function isWorktreeTitleTruncated(
return element.scrollWidth > element.clientWidth
}
const FIELD_RENAME_CURSOR_LOCK_ATTR = 'data-worktree-title-rename-cursor-lock'
const FIELD_RENAME_CURSOR_LOCK_STYLE_ID = 'worktree-title-rename-cursor-lock-style'
const useCursorLockEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect
let fieldRenameCursorLockCount = 0
function acquireFieldRenameCursorLock(): () => void {
if (typeof document === 'undefined') {
return () => {}
}
fieldRenameCursorLockCount += 1
document.documentElement.setAttribute(FIELD_RENAME_CURSOR_LOCK_ATTR, '')
if (!document.getElementById(FIELD_RENAME_CURSOR_LOCK_STYLE_ID)) {
const style = document.createElement('style')
style.id = FIELD_RENAME_CURSOR_LOCK_STYLE_ID
// Why: Chromium can leave the native cursor stale after a stationary pointer
// swaps from wrapped text to an input. Lock cursor only while field editing.
style.textContent = `
html[${FIELD_RENAME_CURSOR_LOCK_ATTR}],
html[${FIELD_RENAME_CURSOR_LOCK_ATTR}] body,
html[${FIELD_RENAME_CURSOR_LOCK_ATTR}] body * {
cursor: text !important;
}
`
document.head.append(style)
}
let released = false
return () => {
if (released) {
return
}
released = true
fieldRenameCursorLockCount = Math.max(0, fieldRenameCursorLockCount - 1)
if (fieldRenameCursorLockCount > 0) {
return
}
document.documentElement.removeAttribute(FIELD_RENAME_CURSOR_LOCK_ATTR)
document.getElementById(FIELD_RENAME_CURSOR_LOCK_STYLE_ID)?.remove()
}
}
type WorktreeTitleInlineRenameProps = {
displayName: string
disabled?: boolean
@@ -87,8 +43,9 @@ type WorktreeTitleInlineRenameProps = {
// onBeginEditingConsumed so the request fires exactly once.
beginEditing?: boolean
onBeginEditingConsumed?: () => void
// Why: parent-triggered rename can place the caret without selecting, while
// double-click rename keeps the native one-keystroke replacement behavior.
// Why: the inline text row selects all for a one-keystroke replace; the hovercard
// field defaults to caret-at-end. A pointer parked over a fresh selection makes
// macOS flip between the I-beam and the drag-selection arrow. Callers can override.
selectOnFocus?: boolean
}
@@ -107,7 +64,7 @@ export function WorktreeTitleInlineRename({
onRename,
beginEditing = false,
onBeginEditingConsumed,
selectOnFocus = true
selectOnFocus = editingPresentation !== 'field'
}: WorktreeTitleInlineRenameProps): React.JSX.Element {
const editingRef = useRef(false)
const savingRef = useRef(false)
@@ -185,13 +142,6 @@ export function WorktreeTitleInlineRename({
const savingInputClassName = editingPresentation === 'field' ? 'pr-6' : 'pr-4'
const savingSpinnerClassName = editingPresentation === 'field' ? 'right-1.5' : 'right-0'
useCursorLockEffect(() => {
if (!editing || editingPresentation !== 'field') {
return
}
return acquireFieldRenameCursorLock()
}, [editing, editingPresentation])
const setEditingMode = useCallback(
(nextEditing: boolean) => {
if (editingRef.current === nextEditing) {