From 80e82130cd3f40af5ae5977bec9245f5404fd4c7 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 25 Aug 2026 14:38:10 -0400 Subject: [PATCH] fix(jetbrains): let overlays take the pointer over from the transcript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .changeset/hover-popup-overlay.md | 5 -- .changeset/overlay-takes-hover.md | 5 ++ .../ai/kilocode/client/session/SessionUi.kt | 19 ++-- .../session/ui/popup/HeaderPopupController.kt | 13 +-- .../views/base/AbstractSessionPartView.kt | 14 ++- .../kilocode/client/ui/LayeredOverlayPanel.kt | 89 +++++++++++++++++-- .../client/agentManager/WorktreeIconsTest.kt | 10 ++- .../ui/popup/HeaderPopupControllerTest.kt | 34 +------ .../views/base/AbstractSessionPartViewTest.kt | 41 +++++++++ .../client/ui/LayeredOverlayPanelTest.kt | 69 ++++++++++++++ 10 files changed, 223 insertions(+), 76 deletions(-) delete mode 100644 .changeset/hover-popup-overlay.md create mode 100644 .changeset/overlay-takes-hover.md diff --git a/.changeset/hover-popup-overlay.md b/.changeset/hover-popup-overlay.md deleted file mode 100644 index 70c75cf242..0000000000 --- a/.changeset/hover-popup-overlay.md +++ /dev/null @@ -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. diff --git a/.changeset/overlay-takes-hover.md b/.changeset/overlay-takes-hover.md new file mode 100644 index 0000000000..eca333f190 --- /dev/null +++ b/.changeset/overlay-takes-hover.md @@ -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. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 7bcb05e784..8d9a07ff8e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -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) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupController.kt index 7c1405aca7..38d9892c34 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupController.kt @@ -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() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt index 20453d2f36..551f6db45a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt @@ -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) } /** diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/LayeredOverlayPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/LayeredOverlayPanel.kt index a9aeedcf79..42b4a4b63f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/LayeredOverlayPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/LayeredOverlayPanel.kt @@ -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 Rectangle>() + private val blocking = linkedSetOf() + + /** 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) -> diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeIconsTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeIconsTest.kt index 4102c0c17f..37b9396f0b 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeIconsTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeIconsTest.kt @@ -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) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupControllerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupControllerTest.kt index f8ca3665dd..5c61d86d53 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupControllerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupControllerTest.kt @@ -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 } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartViewTest.kt index 8f1004f729..b5b80a333c 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartViewTest.kt @@ -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) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/LayeredOverlayPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/LayeredOverlayPanelTest.kt index ef4f95f1c0..8867dbbc0e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/LayeredOverlayPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/LayeredOverlayPanelTest.kt @@ -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