diff --git a/.changeset/session-status-copy-overlay.md b/.changeset/session-status-copy-overlay.md new file mode 100644 index 00000000000..0bdebd2a647 --- /dev/null +++ b/.changeset/session-status-copy-overlay.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Keep JetBrains session status and copy controls visually bounded to the transcript area. 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 4d0a103c12c..8f28b8ead37 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 @@ -333,11 +333,6 @@ class SessionUi( ) } - overlay = SessionHoverCopyOverlay(root, this) - root.addOverlay(overlay) { pane, child -> - overlay.bounds(pane, child) - } - sessionContent = JPanel(BorderLayout()).apply { isOpaque = false } blankBody = JPanel(BorderLayout()).apply { isOpaque = false } @@ -388,6 +383,10 @@ class SessionUi( header = SessionHeaderPanel(controller, this) { openBranchChanges() } scroll = SessionScroll(root, sessionContent, messageBody, blankBody) + overlay = SessionHoverCopyOverlay(root, scroll.component, this) + root.addOverlay(overlay) { pane, child -> + overlay.bounds(pane, child) + } messageBody.onReflow = { on -> if (on && !opening) scroll.followTail() } scroll.onScroll = { overlay.clear() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt index 87452dca888..2ceeb975214 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt @@ -18,6 +18,7 @@ import com.intellij.ui.AnimatedIcon import com.intellij.ui.components.JBLabel import com.intellij.util.ui.JBUI import com.intellij.util.ui.components.BorderLayoutPanel +import java.awt.Color /** * Progress footer rendered at the bottom of the session transcript while the @@ -51,7 +52,7 @@ class ProgressPanel( private val tick = clock.timer(1000) { syncElapsed() } init { - isOpaque = false + isOpaque = true isVisible = false border = JBUI.Borders.empty( UiStyle.Gap.sm(), @@ -83,6 +84,8 @@ class ProgressPanel( /** Exposed for test assertions. */ fun labelForeground() = label.foreground + override fun getBackground(): Color = SessionUiStyle.Colors.sessionBackground() + private fun onState(state: SessionState) { this.state = state when (state) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionHoverCopyOverlay.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionHoverCopyOverlay.kt index 954e3e6084d..ae5afeb3d85 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionHoverCopyOverlay.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionHoverCopyOverlay.kt @@ -18,6 +18,7 @@ import javax.swing.SwingUtilities internal class SessionHoverCopyOverlay( private val root: JComponent, + private val area: JComponent, parent: Disposable, ) : JPanel(null), Disposable { private var target: SessionCopyTarget? = null @@ -57,18 +58,31 @@ internal class SessionHoverCopyOverlay( if (visible.isEmpty) return Rectangle() val size = child.preferredSize val gap = JBUI.scale(4) + val limit = limit(pane) + if (limit.isEmpty) return Rectangle() if (item.copyToolbar != null) { val pt = SwingUtilities.convertPoint(anchor, Point(visible.x, visible.y), pane) - val x = (pt.x + visible.width - size.width).coerceIn(0, (pane.width - size.width).coerceAtLeast(0)) - val y = (pt.y + visible.height - size.height).coerceIn(0, (pane.height - size.height).coerceAtLeast(0)) + val x = clamp(pt.x + visible.width - size.width, limit.x, limit.x + limit.width - size.width) + val y = clamp(pt.y + visible.height - size.height, limit.y, limit.y + limit.height - size.height) return Rectangle(x, y, size.width, size.height) } val pt = SwingUtilities.convertPoint(anchor, Point(visible.x + visible.width, visible.y), pane) - val x = (pt.x - size.width - gap).coerceIn(0, (pane.width - size.width).coerceAtLeast(0)) - val y = (pt.y + gap).coerceIn(0, (pane.height - size.height).coerceAtLeast(0)) + val x = clamp(pt.x - size.width - gap, limit.x, limit.x + limit.width - size.width) + val y = clamp(pt.y + gap, limit.y, limit.y + limit.height - size.height) return Rectangle(x, y, size.width, size.height) } + private fun limit(pane: JPanel): Rectangle { + if (!area.isShowing || area.parent == null) return Rectangle() + val pt = SwingUtilities.convertPoint(area.parent, area.location, pane) + return Rectangle(pt.x, pt.y, area.width, area.height) + } + + private fun clamp(value: Int, min: Int, max: Int): Int { + if (max < min) return min + return value.coerceIn(min, max) + } + override fun doLayout() { child.setBounds(0, 0, width, height) layout(child) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ProgressPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ProgressPanelTest.kt index 2c112f306b6..452f1bae015 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ProgressPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ProgressPanelTest.kt @@ -5,6 +5,7 @@ import ai.kilocode.client.session.model.Permission import ai.kilocode.client.session.model.PermissionMeta import ai.kilocode.client.session.model.SessionModel import ai.kilocode.client.session.model.SessionState +import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.util.UiTimer import ai.kilocode.client.util.UiTimerSource @@ -45,6 +46,11 @@ class ProgressPanelTest : BasePlatformTestCase() { assertFalse(panel.isVisible) } + fun `test panel paints the session background`() { + assertTrue(panel.isOpaque) + assertEquals(SessionUiStyle.Colors.sessionBackground().rgb, panel.background.rgb) + } + fun `test panel shows on Busy with text`() { model.setState(SessionState.Busy("Thinking\u2026")) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionSelectionCopyTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionSelectionCopyTest.kt index 10bab7b4b3e..6959fd08171 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionSelectionCopyTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionSelectionCopyTest.kt @@ -235,7 +235,7 @@ class SessionSelectionCopyTest : SessionUiTestBase() { val root = ShowingPanel() val parent = Disposer.newDisposable("overlay-test") val target = TargetPanel("alpha") - val overlay = SessionHoverCopyOverlay(root, parent) + val overlay = SessionHoverCopyOverlay(root, root, parent) root.setBounds(0, 0, 100, 100) target.setBounds(10, 10, 80, 80) root.add(target) @@ -247,6 +247,29 @@ class SessionSelectionCopyTest : SessionUiTestBase() { assertFalse(overlay.isVisible) } + fun `test hover copy overlay bounds stay inside scroll area`() { + val root = ShowingPanel() + val area = ShowingPanel() + val parent = Disposer.newDisposable("overlay-test") + val target = TargetPanel("alpha") + val overlay = SessionHoverCopyOverlay(root, area, parent) + root.setBounds(0, 0, 200, 200) + area.setBounds(0, 0, 200, 100) + target.setBounds(170, 90, 20, 20) + area.add(target) + root.add(area) + root.add(overlay) + + try { + show(overlay, target) + val bounds = overlay.bounds(root, overlay.components.single() as JComponent) + + assertTrue("copy overlay should stay above the prompt/status area", bounds.y + bounds.height <= area.y + area.height) + } finally { + Disposer.dispose(parent) + } + } + fun `test session context menu can reinstall after parent disposal`() { val root = JPanel(null) val one = Disposer.newDisposable("context-one")