diff --git a/.changeset/jetbrains-worktree-list-fixes.md b/.changeset/jetbrains-worktree-list-fixes.md new file mode 100644 index 0000000000..7aa15c9a92 --- /dev/null +++ b/.changeset/jetbrains-worktree-list-fixes.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Show worktree session titles in regular weight, keep the account switcher hidden when a new worktree starts with a prompt, add new worktrees at the top of the Agent Manager list, keep the running indicator on worktree rows when a stopped session is resumed, and keep session card popups inside the visible session view. 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 7995bb5129..94bb2ff778 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 @@ -161,8 +161,9 @@ 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. 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. + * 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. * * Returns null when the chat is not on screen yet, in which case there is nothing to sit beside. */ @@ -177,10 +178,13 @@ class HeaderPopupController(timers: UiTimerSource = UiTimers) : Disposable { // The shadow is reserved on every side, so it counts twice on each axis. val shadow = UiStyle.Balloon.shadow() * 2 val chromeHeight = insets.top + insets.bottom + shadow - val bounds = Rectangle(pane.size) + // The visible chat rect, not the whole panel: a session clipped by a short tool window or a + // scrolled editor tab must keep its popups inside the part the user can actually see. + val area = SwingUtilities.convertRectangle(chat, chat.visibleRect, pane) + if (area.isEmpty) return null val spot = HeaderPopupGeometry.beside( - pane = bounds, - chat = SwingUtilities.convertRectangle(chat.parent, chat.bounds, pane), + pane = Rectangle(pane.size), + chat = area, fit = HeaderPopupFit( chromeWidth = insets.left + insets.right + UiStyle.Balloon.pointer().height + shadow, chromeHeight = chromeHeight, @@ -192,7 +196,7 @@ class HeaderPopupController(timers: UiTimerSource = UiTimers) : Disposable { built.fitWithin(spot.maxWidth, spot.maxHeight) val row = SwingUtilities.convertPoint(anchor, Point(0, anchor.height / 2), pane) val height = built.component.preferredSize.height + chromeHeight - return Spot(pane, Point(spot.x, HeaderPopupGeometry.centerY(bounds, row.y, height, gap)), spot.position) + return Spot(pane, Point(spot.x, HeaderPopupGeometry.centerY(area, row.y, height, gap)), spot.position) } private class Spot(val pane: JComponent, val point: Point, val position: Balloon.Position) 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 6a790e9b3d..212c6c3773 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 @@ -40,7 +40,12 @@ 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 [chat] 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. + */ 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) @@ -51,20 +56,22 @@ internal object HeaderPopupGeometry { position = if (useRight) Balloon.Position.atRight else Balloon.Position.atLeft, x = if (useRight) chat.x + chat.width else chat.x, maxWidth = room.coerceIn(0, fit.maxWidth), - maxHeight = (pane.height - fit.gap * 2 - fit.chromeHeight).coerceIn(0, fit.maxHeight), + // Height is budgeted against the chat, 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), ) } /** * Vertical pointer target for a body of [height], preferring [y] but keeping the balloon inside - * [pane]. The balloon centres its body on the target, so an unclamped target near an edge would + * [chat]. 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(pane: Rectangle, y: Int, height: Int, gap: Int): Int { + fun centerY(chat: Rectangle, y: Int, height: Int, gap: Int): Int { val half = height / 2 - val top = pane.y + gap + half - val bottom = pane.y + pane.height - gap - half - if (bottom < top) return pane.y + pane.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 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 2c4dec004f..a262e54319 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 @@ -93,34 +93,55 @@ class HeaderPopupGeometryTest { } @Test - fun `height is capped to the pane minus gaps`() { + fun `height is capped to the chat minus gaps`() { val short = HeaderPopupGeometry.beside( pane = Rectangle(0, 0, 2000, 200), chat = Rectangle(0, 0, 300, 200), fit = fit(), ) - // 200 pane, minus both gaps and the chrome the balloon reserves vertically. + // 200 chat, minus both gaps and the chrome the balloon reserves vertically. assertEquals(200 - GAP * 2 - CHROME_HEIGHT, short.maxHeight) } @Test - fun `pointer target keeps a tall body inside the pane`() { - val pane = Rectangle(0, 0, 2000, 1000) + fun `height follows a short chat 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), + fit = fit(), + ) - // Row near the top: target pushed down so the centred body clears the top edge. - assertEquals(310, HeaderPopupGeometry.centerY(pane, y = 20, height = 600, gap = GAP)) - // Row near the bottom: target pulled up. - assertEquals(690, HeaderPopupGeometry.centerY(pane, y = 980, height = 600, gap = GAP)) - // Row with room on both sides is left alone. - assertEquals(500, HeaderPopupGeometry.centerY(pane, y = 500, height = 600, gap = GAP)) + assertEquals(300 - GAP * 2 - CHROME_HEIGHT, spot.maxHeight) } @Test - fun `body taller than the pane is centred instead of clamped to an empty range`() { - val pane = Rectangle(0, 0, 2000, 400) + fun `pointer target keeps the body inside an offset chat`() { + val chat = Rectangle(0, 400, 300, 400) - assertEquals(200, HeaderPopupGeometry.centerY(pane, y = 10, height = 900, gap = GAP)) + // 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)) + } + + @Test + fun `pointer target keeps a tall body inside the chat`() { + val chat = 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)) + // Row near the bottom: target pulled up. + assertEquals(690, HeaderPopupGeometry.centerY(chat, 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)) + } + + @Test + fun `body taller than the chat is centred instead of clamped to an empty range`() { + val chat = Rectangle(0, 0, 300, 400) + + assertEquals(200, HeaderPopupGeometry.centerY(chat, y = 10, height = 900, gap = GAP)) } private fun beside(chat: Rectangle) = HeaderPopupGeometry.beside(