fix(jetbrains): bound session status overlays

This commit is contained in:
kirillk
2026-08-12 13:37:35 -04:00
parent 706a3c3ce7
commit bf0c6c7a9e
6 changed files with 61 additions and 11 deletions
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Keep JetBrains session status and copy controls visually bounded to the transcript area.
@@ -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()
@@ -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) {
@@ -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)
@@ -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"))
@@ -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")