From 322426db17f9d9dc67992f4030e396bd0011dcb7 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 25 Aug 2026 11:38:22 -0400 Subject: [PATCH] fix(jetbrains): anchor session popups on the card, not the session edge Header popups pointed at the edge of the whole session, so a balloon for a card in the middle of a wide transcript was flung to the far side of the session and read as belonging to whatever panel it landed on. The pointer now lands on the edge of the card the popup describes, which keeps it attached to that card. Room is still measured against the window: cards are narrower than the session, so measuring inside the session would leave almost no width for a card in a split editor. Height still comes from the visible session, since a collapsed card header is only a couple of rows tall. --- .../session/ui/popup/HeaderPopupController.kt | 16 +-- .../session/ui/popup/HeaderPopupGeometry.kt | 37 ++++--- .../ui/popup/HeaderPopupGeometryTest.kt | 97 +++++++++++-------- 3 files changed, 89 insertions(+), 61 deletions(-) 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 94bb2ff778..4873f7c297 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 @@ -120,7 +120,7 @@ class HeaderPopupController(timers: UiTimerSource = UiTimers) : Disposable { if (!onHeader && !onPopup) return hideAll() val req = view.headerPopup() ?: return hideAll() val built = req.build() - place(req.anchor, built)?.let { open(req, built, it) } ?: hideAll() + place(view, req.anchor, built)?.let { open(req, built, it) } ?: hideAll() } @RequiresEdt @@ -160,15 +160,16 @@ class HeaderPopupController(timers: UiTimerSource = UiTimers) : Disposable { } /** - * Resolves the pointer target beside the session chat, sizing the body to the space available on - * the chosen side and to the visible height of the chat. Anchoring on the chat rather than the - * hovered row is what keeps the popup off the transcript instead of covering the row the user is - * reading. + * Resolves the pointer target beside [card], the collapsible view the popup belongs to, sizing the + * body to the space available on the chosen side and to the visible height of the chat. Pointing at + * the card rather than the hovered row keeps the popup off the transcript instead of covering the + * row the user is reading, and pointing at the card rather than the session edge keeps the balloon + * attached to the thing it describes. * * Returns null when the chat is not on screen yet, in which case there is nothing to sit beside. */ @RequiresEdt - private fun place(anchor: JComponent, built: HeaderPopupBody): Spot? { + private fun place(card: JComponent, anchor: JComponent, built: HeaderPopupBody): Spot? { val pane = SwingUtilities.getRootPane(anchor)?.layeredPane val chat = ComponentUtil.getParentOfType(SessionRootPanel::class.java, anchor) // A showing anchor implies every ancestor, including the chat, is showing and laid out. @@ -184,7 +185,8 @@ class HeaderPopupController(timers: UiTimerSource = UiTimers) : Disposable { if (area.isEmpty) return null val spot = HeaderPopupGeometry.beside( pane = Rectangle(pane.size), - chat = area, + card = SwingUtilities.convertRectangle(card.parent, card.bounds, pane), + view = area, fit = HeaderPopupFit( chromeWidth = insets.left + insets.right + UiStyle.Balloon.pointer().height + shadow, chromeHeight = chromeHeight, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupGeometry.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupGeometry.kt index 212c6c3773..8fcd252929 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupGeometry.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupGeometry.kt @@ -4,7 +4,7 @@ import com.intellij.openapi.ui.popup.Balloon import java.awt.Rectangle /** - * Where a header popup should sit relative to the session chat, and how large its body may be. + * Where a header popup should sit relative to its card, and how large its body may be. * * [x] is the pointer target in the same coordinate space the placement was computed in. */ @@ -33,7 +33,7 @@ internal data class HeaderPopupFit( /** * Geometry for header popups. Pure functions so the side and fit rules are testable without a frame. * - * Header popups only ever sit beside the chat, never over it and never above or below it. The fit part + * Header popups only ever sit beside their card, never over it and never above or below it. The fit part * is not cosmetic: `BalloonImpl.show` silently re-points a balloon to `BELOW`/`ABOVE` when the * requested rectangle does not fit inside the layered pane, so a body that overflows its side would * land in exactly the placement we are avoiding. Capping the body keeps the requested position. @@ -41,37 +41,42 @@ internal data class HeaderPopupFit( internal object HeaderPopupGeometry { /** - * Picks the side of [chat] with more room inside [pane] and the body box that fits there. + * Picks the side of [card] with more room inside [pane] and the body box that fits there. * - * [pane] only decides which side has room; the body is bounded by [chat] so the popup stays - * within the visible session view. + * The pointer lands on the edge of [card], the collapsible view the popup belongs to, so the + * balloon reads as attached to that card instead of docked to the far edge of the session. Room + * is still measured against [pane]: a card is narrower than the session, and cards near the + * middle of a split editor have almost no room beside them inside the session itself. + * + * [view] is the visible session and only budgets height. Using [card] there would collapse the + * body, since a collapsed card header is a couple of rows tall. */ - fun beside(pane: Rectangle, chat: Rectangle, fit: HeaderPopupFit): HeaderPopupPlacement { - val left = (chat.x - pane.x).coerceAtLeast(0) - val right = (pane.x + pane.width - (chat.x + chat.width)).coerceAtLeast(0) + fun beside(pane: Rectangle, card: Rectangle, view: Rectangle, fit: HeaderPopupFit): HeaderPopupPlacement { + val left = (card.x - pane.x).coerceAtLeast(0) + val right = (pane.x + pane.width - (card.x + card.width)).coerceAtLeast(0) // Ties go right: it matches reading direction and the common tool-window-on-the-left setup. val useRight = right >= left val room = (if (useRight) right else left) - fit.chromeWidth - fit.gap return HeaderPopupPlacement( position = if (useRight) Balloon.Position.atRight else Balloon.Position.atLeft, - x = if (useRight) chat.x + chat.width else chat.x, + x = if (useRight) card.x + card.width else card.x, maxWidth = room.coerceIn(0, fit.maxWidth), - // Height is budgeted against the chat, not the pane: the popup belongs to the session + // Height is budgeted against the session, not the pane: the popup belongs to the session // view, so it must not run past it into editor tabs or neighbouring tool windows. - maxHeight = (chat.height - fit.gap * 2 - fit.chromeHeight).coerceIn(0, fit.maxHeight), + maxHeight = (view.height - fit.gap * 2 - fit.chromeHeight).coerceIn(0, fit.maxHeight), ) } /** * Vertical pointer target for a body of [height], preferring [y] but keeping the balloon inside - * [chat]. The balloon centres its body on the target, so an unclamped target near an edge would + * [view]. The balloon centres its body on the target, so an unclamped target near an edge would * overflow and trigger the same re-pointing that [beside] avoids horizontally. */ - fun centerY(chat: Rectangle, y: Int, height: Int, gap: Int): Int { + fun centerY(view: Rectangle, y: Int, height: Int, gap: Int): Int { val half = height / 2 - val top = chat.y + gap + half - val bottom = chat.y + chat.height - gap - half - if (bottom < top) return chat.y + chat.height / 2 + val top = view.y + gap + half + val bottom = view.y + view.height - gap - half + if (bottom < top) return view.y + view.height / 2 return y.coerceIn(top, bottom) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupGeometryTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupGeometryTest.kt index a262e54319..c984f5ca47 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupGeometryTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupGeometryTest.kt @@ -16,25 +16,40 @@ class HeaderPopupGeometryTest { } @Test - fun `chat on the left points right`() { + fun `card on the left points right`() { // Tool window on the left: the editor area to its right is the roomier side. - val spot = beside(chat = Rectangle(0, 0, 300, 1000)) + val spot = beside(card = Rectangle(0, 0, 300, 40)) assertEquals(Balloon.Position.atRight, spot.position) assertEquals(300, spot.x) } @Test - fun `chat on the right points left`() { - val spot = beside(chat = Rectangle(1700, 0, 300, 1000)) + fun `card on the right points left`() { + val spot = beside(card = Rectangle(1700, 0, 300, 40)) assertEquals(Balloon.Position.atLeft, spot.position) assertEquals(1700, spot.x) } + @Test + fun `the pointer lands on the card edge, not the session edge`() { + // Left-docked chat: cards are inset from the session, so the balloon hugs the card at 760 + // rather than docking to the session edge at 800. + val spot = HeaderPopupGeometry.beside( + pane = Rectangle(0, 0, 2000, 1000), + card = Rectangle(60, 300, 700, 40), + view = Rectangle(0, 0, 800, 1000), + fit = fit(), + ) + + assertEquals(Balloon.Position.atRight, spot.position) + assertEquals(760, spot.x) + } + @Test fun `side with more room wins even when both sides fit`() { - val spot = beside(chat = Rectangle(1200, 0, 300, 1000)) + val spot = beside(card = Rectangle(1200, 0, 300, 40)) // Left room is 1200, right room is 500. assertEquals(Balloon.Position.atLeft, spot.position) @@ -43,14 +58,14 @@ class HeaderPopupGeometryTest { @Test fun `equal room points right`() { - val spot = beside(chat = Rectangle(850, 0, 300, 1000)) + val spot = beside(card = Rectangle(850, 0, 300, 40)) assertEquals(Balloon.Position.atRight, spot.position) } @Test fun `body is capped to the free space on the chosen side`() { - val spot = beside(chat = Rectangle(0, 0, 1800, 1000)) + val spot = beside(card = Rectangle(0, 0, 1800, 40)) // 200 free on the right, minus chrome and gap. assertEquals(200 - CHROME - GAP, spot.maxWidth) @@ -58,14 +73,14 @@ class HeaderPopupGeometryTest { @Test fun `body is capped to the shared max when the side is roomy`() { - val spot = beside(chat = Rectangle(0, 0, 300, 1000)) + val spot = beside(card = Rectangle(0, 0, 300, 40)) assertEquals(CAP, spot.maxWidth) } @Test - fun `a chat filling the pane yields no room rather than a negative width`() { - val spot = beside(chat = Rectangle(0, 0, 2000, 1000)) + fun `a card filling the pane yields no room rather than a negative width`() { + val spot = beside(card = Rectangle(0, 0, 2000, 40)) assertEquals(0, spot.maxWidth) } @@ -73,19 +88,15 @@ class HeaderPopupGeometryTest { @Test fun `chrome is reserved so the balloon still fits its side`() { // The side has 400px; a body of the full 400 would overflow once the balloon adds its border, - // pointer and shadow, and an overflowing balloon gets re-pointed above or below the chat. - val spot = beside(chat = Rectangle(0, 0, 1600, 1000)) + // pointer and shadow, and an overflowing balloon gets re-pointed above or below the card. + val spot = beside(card = Rectangle(0, 0, 1600, 40)) assertTrue(spot.maxWidth + CHROME <= 400) } @Test - fun `a chat with no usable room on either side still resolves to a horizontal side`() { - val tight = HeaderPopupGeometry.beside( - pane = Rectangle(0, 0, 2000, 1000), - chat = Rectangle(0, 0, 1980, 1000), - fit = fit(), - ) + fun `a card with no usable room on either side still resolves to a horizontal side`() { + val tight = beside(card = Rectangle(0, 0, 1980, 40)) // Neither side can fit the chrome, but above/below must never be the answer. assertTrue(tight.position == Balloon.Position.atRight || tight.position == Balloon.Position.atLeft) @@ -93,23 +104,25 @@ class HeaderPopupGeometryTest { } @Test - fun `height is capped to the chat minus gaps`() { + fun `height is capped to the session minus gaps`() { val short = HeaderPopupGeometry.beside( pane = Rectangle(0, 0, 2000, 200), - chat = Rectangle(0, 0, 300, 200), + card = Rectangle(0, 0, 300, 40), + view = Rectangle(0, 0, 300, 200), fit = fit(), ) - // 200 chat, minus both gaps and the chrome the balloon reserves vertically. + // 200 session, minus both gaps and the chrome the balloon reserves vertically. assertEquals(200 - GAP * 2 - CHROME_HEIGHT, short.maxHeight) } @Test - fun `height follows a short chat inside a tall pane`() { + fun `height follows a short session inside a tall pane`() { // Session in an editor tab or a short tool window: the window has room the session does not. val spot = HeaderPopupGeometry.beside( pane = Rectangle(0, 0, 2000, 1000), - chat = Rectangle(0, 100, 300, 300), + card = Rectangle(0, 100, 300, 40), + view = Rectangle(0, 100, 300, 300), fit = fit(), ) @@ -117,36 +130,44 @@ class HeaderPopupGeometryTest { } @Test - fun `pointer target keeps the body inside an offset chat`() { - val chat = Rectangle(0, 400, 300, 400) + fun `height follows the session even when the card is a collapsed header`() { + val spot = beside(card = Rectangle(0, 0, 300, 30)) - // Rows above and below the chat are pulled back into it. - assertEquals(560, HeaderPopupGeometry.centerY(chat, y = 0, height = 300, gap = GAP)) - assertEquals(640, HeaderPopupGeometry.centerY(chat, y = 1000, height = 300, gap = GAP)) + assertEquals(CAP_HEIGHT, spot.maxHeight) } @Test - fun `pointer target keeps a tall body inside the chat`() { - val chat = Rectangle(0, 0, 300, 1000) + fun `pointer target keeps the body inside an offset session`() { + val view = Rectangle(0, 400, 300, 400) + + // Rows above and below the session are pulled back into it. + assertEquals(560, HeaderPopupGeometry.centerY(view, y = 0, height = 300, gap = GAP)) + assertEquals(640, HeaderPopupGeometry.centerY(view, y = 1000, height = 300, gap = GAP)) + } + + @Test + fun `pointer target keeps a tall body inside the session`() { + val view = Rectangle(0, 0, 300, 1000) // Row near the top: target pushed down so the centred body clears the top edge. - assertEquals(310, HeaderPopupGeometry.centerY(chat, y = 20, height = 600, gap = GAP)) + assertEquals(310, HeaderPopupGeometry.centerY(view, y = 20, height = 600, gap = GAP)) // Row near the bottom: target pulled up. - assertEquals(690, HeaderPopupGeometry.centerY(chat, y = 980, height = 600, gap = GAP)) + assertEquals(690, HeaderPopupGeometry.centerY(view, y = 980, height = 600, gap = GAP)) // Row with room on both sides is left alone. - assertEquals(500, HeaderPopupGeometry.centerY(chat, y = 500, height = 600, gap = GAP)) + assertEquals(500, HeaderPopupGeometry.centerY(view, y = 500, height = 600, gap = GAP)) } @Test - fun `body taller than the chat is centred instead of clamped to an empty range`() { - val chat = Rectangle(0, 0, 300, 400) + fun `body taller than the session is centred instead of clamped to an empty range`() { + val view = Rectangle(0, 0, 300, 400) - assertEquals(200, HeaderPopupGeometry.centerY(chat, y = 10, height = 900, gap = GAP)) + assertEquals(200, HeaderPopupGeometry.centerY(view, y = 10, height = 900, gap = GAP)) } - private fun beside(chat: Rectangle) = HeaderPopupGeometry.beside( + private fun beside(card: Rectangle) = HeaderPopupGeometry.beside( pane = Rectangle(0, 0, 2000, 1000), - chat = chat, + card = card, + view = Rectangle(0, 0, 2000, 1000), fit = fit(), )