fix(jetbrains): let overlays take the pointer over from the transcript

Replace the popup-level overlay suppression with hit-testing truth. A card's
hover exit test now asks which component is topmost at that point instead of
only comparing bounds, so an overlay painted above the transcript counts as
having left the row. An overlay child can declare that it blocks the content
beneath it, and the layered panel then releases the hover of whatever the
pointer rests on when such a cover appears, moves, or hides — Swing delivers
no exit for that case on its own.

The connection banner is the first blocking overlay, so a card it covers no
longer stays lit and no longer keeps its hover popup open behind the banner.

Also repair the worktree icon palette test the mid-tone glyph change broke.
This commit is contained in:
kirillk
2026-08-25 14:38:10 -04:00
parent 4078d7cf0d
commit 80e82130cd
10 changed files with 223 additions and 76 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/kilo-jetbrains": patch
---
Hide the session hover popup while a blocking overlay (connection banner or modal blocker) covers the chat.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Let session overlays such as the connection banner take the pointer over from the transcript beneath them, so a covered card no longer stays hovered or keeps its popup open behind the overlay.
@@ -216,7 +216,7 @@ class SessionUi(
private var modalFocus: (() -> JComponent)? = null
private var style = SessionEditorStyle.current()
private val selection = SessionSelection()
private val popup = HeaderPopupController(timers) { overlayShown() }
private val popup = HeaderPopupController(timers)
private val readonly: Boolean get() = manager?.readonly == true
private val provider = object : TextCopyProvider() {
override fun getActionUpdateThread() = ActionUpdateThread.EDT
@@ -336,17 +336,9 @@ class SessionUi(
internal fun setModalContent(content: JComponent?, maxW: (() -> Int)? = null, focus: (() -> JComponent)? = null) {
modalFocus = if (content == null) null else focus
if (content != null) popup.hideAll()
root.setModalContent(content, maxW)
}
// A blocking overlay — the modal blocker or the connection banner — must not have a hover popup
// floating on top of it, so the popup controller checks this before showing or keeping one alive.
@RequiresEdt
private fun overlayShown(): Boolean =
(this::root.isInitialized && root.blocker.isVisible) ||
(this::connection.isInitialized && connection.isVisible)
private fun buildUi() {
root = SessionRootPanel()
// Containers stay transparent over the single self-rendered session root backdrop.
@@ -469,7 +461,9 @@ class SessionUi(
hostedInEditorTab = manager?.hostedInEditorTab == true,
)
connection = ConnectionPanel(this, controller)
root.addOverlay(connection) { pane, child ->
// The banner reports a broken session, so it owns the pointer where it sits: the transcript
// under it must not stay hovered and keep a popup open behind it.
root.addOverlay(connection, blocks = true) { pane, child ->
val size = child.preferredSize
if (readonly) {
val gap = SessionUiStyle.View.contentGap()
@@ -635,10 +629,7 @@ class SessionUi(
prompt.setReady(controller.model.isReady())
}
// The banner reacts to the same event; drop any hover popup so it cannot linger on
// top of the overlay that is about to cover the session.
is SessionControllerEvent.ConnectionChanged ->
if (event !is SessionControllerEvent.ConnectionChanged.Hide) popup.hideAll()
is SessionControllerEvent.ConnectionChanged -> Unit
is SessionControllerEvent.AccountOverlayChanged -> account.onEvent(event)
}
@@ -34,15 +34,7 @@ import javax.swing.SwingUtilities
* Popup subtree hover is detected via [HoverListener] (an experimental IntelliJ API) so the nested
* editor counts as "inside the popup".
*/
/**
* @param suppressed reports whether a blocking overlay (connection banner, modal blocker) currently
* covers the session; while true the hover popup is neither opened nor kept alive so it cannot sit
* on top of the overlay.
*/
class HeaderPopupController(
timers: UiTimerSource = UiTimers,
private val suppressed: () -> Boolean = { false },
) : Disposable {
class HeaderPopupController(timers: UiTimerSource = UiTimers) : Disposable {
private var target: PartView? = null
private var balloon: Balloon? = null
private var body: Disposable? = null
@@ -54,7 +46,6 @@ class HeaderPopupController(
@RequiresEdt
fun show(view: PartView) {
if (suppressed()) return hideAll()
if (target === view) {
onHeader = true
reevaluate()
@@ -126,7 +117,7 @@ class HeaderPopupController(
@RequiresEdt
private fun display() {
val view = target ?: return
if (suppressed() || (!onHeader && !onPopup)) return hideAll()
if (!onHeader && !onPopup) return hideAll()
val req = view.headerPopup() ?: return hideAll()
val built = req.build()
place(view, req.anchor, built)?.let { open(req, built, it) } ?: hideAll()
@@ -301,9 +301,21 @@ abstract class AbstractSessionPartView(
}
}
/**
* Whether the pointer is still on the row. Bounds alone are not enough: an overlay painted above
* the transcript (the connection banner, the modal blocker) owns the pointer while sitting inside
* the row's rectangle, and Swing stops delivering to the row without ever leaving it
* geometrically. Asking which component is topmost at that point treats a covered row as left, so
* the exit clears the hover instead of keeping the row lit — and its popup alive — under the
* overlay.
*/
private fun inside(e: MouseEvent): Boolean {
val point = SwingUtilities.convertPoint(e.component, e.point, row)
return row.contains(point)
if (!row.contains(point)) return false
val pane = SwingUtilities.getRootPane(row)?.layeredPane ?: return true
val spot = SwingUtilities.convertPoint(e.component, e.point, pane)
val top = SwingUtilities.getDeepestComponentAt(pane, spot.x, spot.y) ?: return true
return SwingUtilities.isDescendingFrom(top, row)
}
/**
@@ -7,12 +7,20 @@ import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.components.BorderLayoutPanel
import java.awt.BorderLayout
import java.awt.Component
import java.awt.Container
import java.awt.Dimension
import java.awt.GraphicsEnvironment
import java.awt.MouseInfo
import java.awt.Point
import java.awt.Rectangle
import java.awt.event.ComponentAdapter
import java.awt.event.ComponentEvent
import java.awt.event.MouseEvent
import javax.swing.JComponent
import javax.swing.JLayeredPane
import javax.swing.JPanel
import javax.swing.SwingUtilities
open class LayeredOverlayPanel(
content: JPanel = BorderLayoutPanel(),
@@ -32,6 +40,17 @@ open class LayeredOverlayPanel(
open val blocker: Blocker get() = baseBlocker
// An overlay that starts covering the pointer takes the hover over from the content below it.
// Swing already stops delivering mouse events to a covered component, but it sends no exit when
// the cover appears or moves without the pointer moving, so the content would keep its hover —
// and any hover-driven popup — alive behind the overlay.
private val cover = object : ComponentAdapter() {
override fun componentShown(e: ComponentEvent) = takeOverHover()
override fun componentHidden(e: ComponentEvent) = takeOverHover()
override fun componentMoved(e: ComponentEvent) = takeOverHover()
override fun componentResized(e: ComponentEvent) = takeOverHover()
}
init {
layout = null
add(baseContent)
@@ -41,10 +60,17 @@ open class LayeredOverlayPanel(
add(baseBlocker)
setLayer(baseBlocker, MODAL_LAYER)
baseBlocker.isVisible = false
baseOverlay.cover = cover
baseBlocker.addComponentListener(cover)
}
fun addOverlay(child: JComponent, bounds: (JPanel, JComponent) -> Rectangle) {
overlay.addOverlay(child, bounds)
/**
* Adds a floating child above the content. A child that [blocks] owns the pointer where it sits:
* it takes the hover over from the content beneath it, which a decoration painted for the content
* below (a hover affordance of the very row it sits on) must not do.
*/
fun addOverlay(child: JComponent, blocks: Boolean = false, bounds: (JPanel, JComponent) -> Rectangle) {
overlay.addOverlay(child, blocks, bounds)
}
@RequiresEdt
@@ -89,6 +115,41 @@ open class LayeredOverlayPanel(
}
}
/**
* Hands the hover of the content under the pointer over to the overlay that now covers it.
* Deferred because the trigger can arrive mid-layout, while a hover handler is free to close a
* popup or re-lay out the card it belongs to.
*/
private fun takeOverHover() = SwingUtilities.invokeLater(::releaseHover)
@RequiresEdt
private fun releaseHover() {
if (GraphicsEnvironment.isHeadless() || !isShowing) return
val point = MouseInfo.getPointerInfo()?.location ?: return
SwingUtilities.convertPointFromScreen(point, this)
releaseHover(point)
}
/** Releases the hover of the content at [point], in this panel's coordinates, when covered. */
@RequiresEdt
internal fun releaseHover(point: Point) {
if (!covered(point)) return
val local = SwingUtilities.convertPoint(this, point, content)
val below = SwingUtilities.getDeepestComponentAt(content, local.x, local.y) ?: return
val spot = SwingUtilities.convertPoint(this, point, below)
below.dispatchEvent(
MouseEvent(below, MouseEvent.MOUSE_EXITED, System.currentTimeMillis(), 0, spot.x, spot.y, 0, false),
)
}
/** Whether the blocker or a blocking overlay child sits above the content at [point]. */
private fun covered(point: Point): Boolean {
if (!Rectangle(size).contains(point)) return false
if (blocker.isVisible) return true
val local = SwingUtilities.convertPoint(this, point, overlay)
return overlay.blocks(local.x, local.y)
}
override fun getPreferredSize(): Dimension {
val w = listOf(content, overlay).maxOfOrNull { it.preferredSize.width } ?: 0
val h = listOf(content, overlay).maxOfOrNull { it.preferredSize.height } ?: 0
@@ -99,22 +160,32 @@ open class LayeredOverlayPanel(
private val items = linkedMapOf<JComponent, (JPanel, JComponent) -> Rectangle>()
private val blocking = linkedSetOf<JComponent>()
/** Notified when a blocking child is shown, hidden, moved, or resized. */
internal var cover: ComponentAdapter? = null
init {
layout = null
isOpaque = false
}
fun addOverlay(child: JComponent, bounds: (JPanel, JComponent) -> Rectangle) {
fun addOverlay(child: JComponent, blocks: Boolean = false, bounds: (JPanel, JComponent) -> Rectangle) {
items[child] = bounds
if (blocks) {
blocking.add(child)
cover?.let(child::addComponentListener)
}
add(child)
}
override fun contains(x: Int, y: Int): Boolean {
for (child in components) {
if (child.isVisible && child.bounds.contains(x, y) && child.contains(x - child.x, y - child.y)) return true
}
return false
}
override fun contains(x: Int, y: Int): Boolean = components.any { hits(it, x, y) }
/** Whether a child that blocks the content beneath it covers ([x], [y]). */
internal fun blocks(x: Int, y: Int): Boolean = blocking.any { hits(it, x, y) }
private fun hits(child: Component, x: Int, y: Int): Boolean =
child.isVisible && child.bounds.contains(x, y) && child.contains(x - child.x, y - child.y)
override fun doLayout() {
items.forEach { (child, bounds) ->
@@ -23,13 +23,15 @@ class WorktreeIconsTest : BasePlatformTestCase() {
fun `test resting row icons carry the muted palette in both themes`() {
for (name in listOf("worktreeBranch", "worktreeLock", "worktree-local")) {
// The secondary New UI greys, which are also what Label.infoForeground resolves to, so a
// resting glyph sits at the weight of the description line under it rather than the title.
val light = svg(name).replace("#818594", "GLYPH")
val dark = svg("${name}_dark").replace("#6F737A", "GLYPH")
// The tertiary New UI greys: a resting glyph only says what the checkout is, so it sits a
// step quieter than the secondary grey the description line under it uses.
val light = svg(name).replace("#A8ADBD", "GLYPH")
val dark = svg("${name}_dark").replace("#9DA0A8", "GLYPH")
assertFalse("$name still uses a primary grey", light.contains("#6C707E"))
assertFalse("${name}_dark still uses a primary grey", dark.contains("#CED0D6"))
assertFalse("$name still uses the secondary grey", light.contains("#818594"))
assertFalse("${name}_dark still uses the secondary grey", dark.contains("#6F737A"))
// Recoloring must be the only difference: the loader animates between the two.
assertEquals("$name geometry drifted from its dark variant", light, dark)
}
@@ -70,38 +70,8 @@ class HeaderPopupControllerTest : BasePlatformTestCase() {
assertEquals(0, view.requests)
}
fun `test an overlay suppresses the hover popup`() {
var overlay = false
val controller = controller { overlay }
val view = view()
overlay = true
controller.show(view)
// A blocking overlay leaves no target pending and the dwell never asks for a popup body.
assertNull(target(controller))
assertNull(guard(controller))
timers.advanceBy(500)
assertEquals(0, view.requests)
}
fun `test an overlay appearing during the dwell cancels the popup`() {
var overlay = false
val controller = controller { overlay }
val view = view()
controller.show(view)
assertNotNull(target(controller))
overlay = true
timers.advanceBy(500)
assertNull(target(controller))
assertEquals(0, view.requests)
}
private fun controller(suppressed: () -> Boolean = { false }): HeaderPopupController {
val item = HeaderPopupController(timers, suppressed)
private fun controller(): HeaderPopupController {
val item = HeaderPopupController(timers)
controllers.add(item)
return item
}
@@ -13,7 +13,9 @@ import java.awt.image.BufferedImage
import javax.swing.Icon
import javax.swing.JComponent
import javax.swing.JLabel
import javax.swing.JLayeredPane
import javax.swing.JPanel
import javax.swing.JRootPane
@Suppress("UnstableApiUsage")
class AbstractSessionPartViewTest : BasePlatformTestCase() {
@@ -193,6 +195,45 @@ class AbstractSessionPartViewTest : BasePlatformTestCase() {
assertEquals(SessionUiStyle.View.Surface.headerBgColor().rgb, row.background.rgb)
}
fun `test hover survives an exit that stays on the row`() {
val view = NestedView(JLabel("link"))
val row = view.component(0) as JPanel
pane(view)
enter(row)
// Swing reports an exit for every nested crossing; one that lands back on the row is not a
// leave, so the fill must stay.
exit(row, 5, 5)
assertEquals(SessionUiStyle.View.Surface.headerHoverBgColor().rgb, row.background.rgb)
}
fun `test hover clears when an overlay covers the row under the pointer`() {
val view = NestedView(JLabel("link"))
val row = view.component(0) as JPanel
val pane = pane(view)
enter(row)
assertEquals(SessionUiStyle.View.Surface.headerHoverBgColor().rgb, row.background.rgb)
// A banner painted above the transcript owns the pointer even while it sits inside the row's
// bounds, so the row must not stay lit underneath it.
pane.add(JPanel().apply { setBounds(0, 0, 200, 40) }, JLayeredPane.PALETTE_LAYER)
exit(row, 5, 5)
assertEquals(SessionUiStyle.View.Surface.headerBgColor().rgb, row.background.rgb)
}
private fun pane(view: AbstractSessionPartView): JLayeredPane {
val root = JRootPane()
root.setSize(200, 40)
root.contentPane.add(view)
view.setSize(200, 40)
view.doLayout()
root.doLayout()
root.contentPane.doLayout()
return root.layeredPane
}
fun `test clicking a nested header child toggles the card`() {
val child = JLabel("plain")
val header = JPanel(BorderLayout()).apply { add(child, BorderLayout.WEST) }
@@ -3,7 +3,10 @@ package ai.kilocode.client.ui
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.util.ui.components.BorderLayoutPanel
import java.awt.Dimension
import java.awt.Point
import java.awt.Rectangle
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import javax.swing.JLayeredPane
@Suppress("UnstableApiUsage")
@@ -102,6 +105,72 @@ class LayeredOverlayPanelTest : BasePlatformTestCase() {
assertTrue(root.blocker.contains(50, 50))
}
fun `test a blocking overlay releases the hover of the content it covers`() {
val root = LayeredOverlayPanel().apply { setSize(400, 260) }
val hovered = Hovered()
root.content.add(hovered)
root.addOverlay(Probe(), blocks = true) { _, item -> Rectangle(0, 0, item.preferredSize.width, item.preferredSize.height) }
root.doLayout()
root.releaseHover(Point(20, 10))
assertEquals(1, hovered.exits)
}
fun `test content keeps its hover where no blocking overlay covers it`() {
val root = LayeredOverlayPanel().apply { setSize(400, 260) }
val hovered = Hovered()
root.content.add(hovered)
root.addOverlay(Probe(), blocks = true) { _, item -> Rectangle(0, 0, item.preferredSize.width, item.preferredSize.height) }
root.doLayout()
root.releaseHover(Point(200, 200))
assertEquals(0, hovered.exits)
}
fun `test a decorating overlay leaves the hover of the content below alone`() {
val root = LayeredOverlayPanel().apply { setSize(400, 260) }
val hovered = Hovered()
root.content.add(hovered)
// A hover affordance drawn for the row it sits on must not take that row's hover away.
root.addOverlay(Probe()) { _, item -> Rectangle(0, 0, item.preferredSize.width, item.preferredSize.height) }
root.doLayout()
root.releaseHover(Point(20, 10))
assertEquals(0, hovered.exits)
}
fun `test the blocker releases the hover of the content under the pointer`() {
val root = LayeredOverlayPanel().apply { setSize(400, 260) }
val hovered = Hovered()
root.content.add(hovered)
root.doLayout()
root.releaseHover(Point(20, 10))
assertEquals(0, hovered.exits)
root.setBlocked(true)
root.releaseHover(Point(20, 10))
assertEquals(1, hovered.exits)
}
private class Hovered : BorderLayoutPanel() {
var exits = 0
private set
init {
setBounds(0, 0, 400, 260)
addMouseListener(object : MouseAdapter() {
override fun mouseExited(e: MouseEvent) {
exits++
}
})
}
}
private class Probe : BorderLayoutPanel() {
var laid = false