Skip to content

web: project selector caret flashes on hover — search input and menu fight over DOM focus #56868

Open

Description

@tsehla42

Summary

Hovering rows in the new-session project selector makes the caret in the
"Search projects" input flash. The DOM focus is stolen and restored on every
hover, which also restarts the caret blink. Keyboard users lose typing focus
into the search box mid-hover.

Repro

  1. Open the chat/new-session page in the web app
  2. Open the project selector dropdown (bottom bar project button)
  3. Sweep the mouse across the project rows

Observed: caret blinks off/on continuously while moving across rows.
Expected: caret stays steady; hover only changes the row highlight.

Evidence (focus log, pre-fix)

A focusin listener on the page while sweeping three rows logs:

option:project::C%3A%2FOpenCode%2FHoverAlpha
DIV
option:project::C%3A%2FOpenCode%2FHoverBeta
DIV
option:project::C%3A%2FOpenCode%2FHoverGamma

Every hover steals focus from the search input (row focus + content focus)
and the selector's focusSearch() (setTimeout + rAF .focus()) puts it
back — a focus tug-of-war that flaps per hover and per pointer move.

Root cause

Two competing behaviors:

  1. Kobalte Menu.Item.onPointerMove calls focusWithoutScrolling(row) on
    every mouse move, and row pointerleave focuses the menu content
    (_focusContent()).
  2. packages/app/src/new-session/project/selector.tsx compensates in
    onMouseEnter with focusSearch() (rAF .focus() on the search input)
    to keep search-as-you-type working.

The same compensation pattern exists in
packages/app/src/providers/models/select-dialog.tsx (model selector).

Suggested fix

While an editable element inside the menu has focus, pointer hover should
never move DOM focus — visual highlight is already handled app-side
(active + classList). Applied at the three steal sites in @kobalte/core
(_focusContent, item onPointerMove, sub-trigger onPointerMove, the
latter keeping submenu hover-open). Note pointerleave is not cancelable,
so a defaultPrevented guard alone can't fix it.

Regression test: packages/app/e2e/regression/project-selector.spec.ts
(focus retention while hovering + highlight/aria-activedescendant/search
guards) — RED before the patch, green after; 47 related e2e tests pass.

Fix is prepared as a @kobalte/core patch (patches/@kobalte%2Fcore@0.13.13.patch)
following the repo's existing patched-dependencies convention.

Activity

  1. opencode-agent commented on Oct 18, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report. I reproduced this on the latest dev (501b15a) in the web app.

    Steps: I opened a new-session draft with three projects (HoverAlpha, HoverBeta, HoverGamma), opened the project selector from the bottom bar and moved the mouse across the rows while logging focusin events.

    Focus moves on every row I hover: the row, then Search projects, then the row again, then the menu content (prompt-project-menu). This matches the log in your report. The Playwright script below reproduces it; with the current code its focus assertion fails.

    I only tested the project selector; I haven't checked the model selector you mentioned.

    Reproduction script (project-selector-focus.spec.ts)
    // Run from a dev checkout: copy to packages/app/e2e/regression/ and run
    // `cd packages/app && bunx playwright install chromium && bunx playwright test e2e/regression/project-selector-focus.spec.ts`
    import { expect, test } from "@playwright/test"
    import { openDraft } from "../utils/workspace"
    
    test.use({ serviceWorkers: "block", viewport: { width: 1280, height: 900 } })
    
    test("hovering project rows keeps DOM focus in the search input (#56868)", async ({ page }) => {
      const dirs = ["C:/OpenCode/HoverAlpha", "C:/OpenCode/HoverBeta", "C:/OpenCode/HoverGamma"]
      await openDraft(page, {
        name: "HoverAlpha",
        seed: {
          projects: { local: dirs.map((worktree) => ({ worktree, expanded: true })) },
        },
      })
      await page.locator('[data-component="new-session"] [data-action="prompt-project"]').first().click()
      const search = page.getByPlaceholder(/Search projects/i)
      await expect(search).toBeFocused()
    
      await page.evaluate(() => {
        ;(window as any).__focusLog = []
        document.addEventListener("focusin", (e) => {
          const el = e.target as HTMLElement
          ;(window as any).__focusLog.push(el.id || el.getAttribute("placeholder") || el.tagName)
        })
      })
    
      const rows = page.locator('[role="menuitemradio"]')
      const count = await rows.count()
      console.log("rows:", count, await rows.allTextContents())
      for (let i = 0; i < count; i++) {
        const box = (await rows.nth(i).boundingBox())!
        for (let x = 20; x < 120; x += 20) await page.mouse.move(box.x + x, box.y + box.height / 2)
      }
      const log = await page.evaluate(() => (window as any).__focusLog)
      console.log("FOCUS LOG:\n" + log.join("\n"))
      await page.screenshot({ path: "hover.png" })
      expect(log).toEqual([])
    })

    Run it: bun project-selector-focus.spec.ts

  2. tsehla42 commented on Oct 27, 2026

    @tsehla42
    ContributorAuthor

    Status update after the location redesign on dev: the dropdown from the original repro is gone (the project trigger now opens the location dialog), so those exact steps no longer reproduce. The same focus fight survived in the re-homed searchable worktree menu (session/composer/location-missing.tsx — its keydown listener that sends typing back to the search field is working around precisely this). PR #56873 has been merged with current dev and re-homed: the guard is unchanged, the Enter-forwarding moved to that menu, and the regression case now lives in session-location-sync.spec.ts. Keeping this issue for the PR to close.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions