feat(desktop): hover cards stay open while the pointer is on them

Merged
DA
8d ago
+326-18

Summary

Dean: "For the hover cards, I want them to stay open if I hover over them, not disappear when I move my mouse off the trigger basically."

The shared Tooltip's card mode covers the agent's and subagent's tab cards (#2049) and the sidebar plan card (#2074). It now works like Radix's HoverCard or GitHub's hovercards:

  • Linger: when the pointer leaves a card's control, the card stays for 180ms, long enough to cross the 6px gap onto it, even on a slant. With the pointer on the card it stays open. When the pointer leaves the card for anywhere except its control, the card closes after the same 180ms. Moving back onto the control also keeps it open. The delay was enough for diagonal moves, so I didn't add a safe-polygon.
  • Selectable: the card now takes the pointer (pointer-events: auto) and its text can be selected (user-select: text). While the pointer is on a card, only Escape closes it, so ⌘C still reaches the selection. Clicking in the card doesn't close it, even though that takes focus from the control.
  • No invisible catch: if the pointer leaves a card that is still waiting out the hint delay, the card closes at once, like a pill. While it waits, the card is visibility: hidden (not just transparent), so it can't catch the pointer from a control beneath it. A press that starts on the card keeps it open while a selection is dragged past its edge.
  • One at a time: showing any tooltip closes a lingering card immediately. Moving from one tab to the next swaps the card, and the warm spell still makes the next one appear without the delay.
  • Plain tooltips are unchanged: labels and shortcut pills still close as soon as the pointer leaves.

Clickable parts: none added. Neither card has anything clickable today, and the plan card's PR line carries no URL. Making a line clickable would turn the card into a focus-managed popover (a dialog role, Tab into the card, Tab out to the next control). That's more than this small PR should take on, so it can be a follow-up (e.g. the PR line opens GitHub). Accessibility pattern: since a card holds nothing to press, it stays a description (role="tooltip" plus aria-describedby on the control). Keyboard focus on the control opens it as before, focus never moves into it, and Escape closes it.

How: the linger is a single keep-alive atom over Effect.sleep on the renderer runtime, the same way copy-feedback.tsx and spinner.tsx wait. It adds no raw timer and no new run site, and the tests drive it with TestClock.

Evidence

  • Platform-independent checks: ./scripts/check.sh exit 0.

  • macOS Electron verification (./scripts/verify.sh): not run. I built this on a Linux cloud VM, and CI cannot verify the Mac UI (no macOS job on PRs; LUKE-159 is the manual Mac pass).

  • tooltip.test.ts: 6 new tests through the public Tooltip on TestClock:

    • crossing from the control onto the card keeps it open; leaving the card closes it after the linger
    • leaving the control lingers; coming back keeps it open
    • a card still waiting closes at once
    • another control's hint closes a lingering card at once
    • a key leaves the card open while the pointer is on it, Escape closes it, a press keeps it open
    • a selection dragged past the card's edge keeps it open until the press ends

    Against the old component, 3 of the new tests fail, and the drag test fails without its fix. The other two cover the new branches (the waiting check and one-at-a-time). The existing "leaving takes it down" test covers plain tooltips being unchanged. Test count: 10 → 16 in this file.

Visual evidence

To screenshot it, I bundled the real Tooltip and PlanCard with the built styles.css into a page and drove headless Chrome over CDP. The script hovered a plan row, moved diagonally onto the card in 6 steps, waited 1.5s (card still open), and drag-selected the PR title (selection "#2087 Ā· feat(desktop): hover", card still open). A drag held 600ms past the card's edge left it open. During the hint delay, elementFromPoint over the waiting card missed it. After the pointer left the card, it was still open at 60ms and gone at 360ms.

The plan card kept open with the pointer on it and its pull request's title selected

Invariants

  • ./scripts/check.sh green; renderer/UI PRs also ./scripts/verify.sh with evidence inspected. (CI): check green; verify.sh not run (Linux VM)
  • JSON Schema goldens unchanged (LUKE_UPDATE_FIXTURES not run, or diff explained line by line). (CI)
  • Every behavior change has, in this PR, the test that fails without it; no change-detector test added. (manual; root AGENTS.md "Testing")
  • Gateway envelope goldens unchanged. (CI)
  • No new as type assertion outside the allowlisted files. (CI via anti-slop)
  • No Effect.runPromise/runSync/runFork outside the runtime edges and the shims tools/oxlint/anti-slop/effect-edges.json records. (CI via anti-slop/no-run-promise-outside-edges)
  • No new setTimeout/setInterval/new Promise/AbortController/fs.watch outside the files tools/oxlint/anti-slop/effect-edges.json records. (CI via anti-slop/no-raw-async-primitives)
  • No Effect, Stream, or Layer written as a statement and dropped; a description a caller walks past runs nothing. (CI via pnpm discarded-effect)
  • Test count equals the pre-PR count or the delta is listed: +6 in tooltip.test.ts.
  • Each hand-rolled file the PR replaces is deleted, or the part that remains is a permanent adaptor named in root AGENTS.md's "The permanent adaptors" with the reason it stays: none replaced.
  • Every CLAUDE.md/AGENTS.md sentence naming a changed part is edited; root pair stays byte-identical: none name the tooltip.
  • PRIVACY.md unchanged. The card is still ph-no-capture and left out of the recording.
  • Package package.json deps match what the sources import by bare specifier; effect via catalog:. (CI)
  • Barrel/door rule: no Node-reaching Effect module behind a barrel the renderer or a web function opens. (CI)

Notes

  • Blockers or follow-up verification: the Mac UI is unverified by CI. LUKE-159's manual verify.sh pass covers it. Possible follow-up: a clickable PR line in the plan card (needs the popover/focus pattern above).

šŸ¤– Generated with Claude Code