feat(agent-manager): reveal worktree and session row actions on hover

Show the ActiveList action bar only for the row the mouse is hovering in
the Agent Manager worktree list and the worktree-editor session list,
gated on that row also being selected. Other ActiveList consumers keep
the existing selection-based reveal.

Track hover state in ActiveListView behind a new
ActiveListConfig.hoverActions flag, scope repaints to the affected row
cells, and repaint the hovered row on selection changes so the rule
applies immediately. The action overlay background now mirrors the row
background instead of always painting a focused-selection highlight.
This commit is contained in:
kirillk
2026-08-03 15:16:54 -04:00
parent 5ab0ba24b3
commit aafde0bf7e
7 changed files with 190 additions and 8 deletions
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Show Agent Manager row actions only while hovering worktree and session rows.
@@ -17,6 +17,7 @@ import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.list.ActiveList
import ai.kilocode.client.ui.list.ActiveListBadge
import ai.kilocode.client.ui.list.ActiveListCell
import ai.kilocode.client.ui.list.ActiveListConfig
import ai.kilocode.client.ui.list.ActiveListDeleteOptions
import ai.kilocode.client.ui.list.ActiveListItem
import ai.kilocode.client.ui.list.ActiveListSelection
@@ -67,6 +68,7 @@ class AgentManagerPanel(
private val edit = RenameAction()
private val list = ActiveList(
KiloBundle.message("worktree.empty"),
cfg = ActiveListConfig(hoverActions = true),
surface = ActiveListSurface.ToolWindow,
showSearch = false,
onCell = { key, id ->
@@ -66,6 +66,7 @@ class WorktreeSessionEditorPanel(
ActiveListRowHeight.EQUAL,
description = false,
selection = ListSelectionModel.MULTIPLE_INTERVAL_SELECTION,
hoverActions = true,
),
surface = ActiveListSurface.ToolWindow,
showSearch = false,
@@ -24,6 +24,7 @@ internal data class ActiveListConfig(
val descriptionIndent: Boolean = true,
val tooltip: Boolean = true,
val selection: Int = ListSelectionModel.SINGLE_SELECTION,
val hoverActions: Boolean = false,
) {
companion object {
val Equal = ActiveListConfig(ActiveListRowHeight.EQUAL)
@@ -159,12 +159,14 @@ internal class ActiveListRenderer(
trail.isVisible = end.isNotBlank()
trail.foreground = weak
// In-place action buttons follow the selection highlight: only when the selection is
// visible (list focused, or an owned popup is active). An unfocused list hides them.
syncCells(value, active && list.isEnabled, list.isEnabled)
val hovered = (list as? ActiveListActive)?.hoveredIndex() == index
val show = if (cfg.hoverActions) list.isEnabled && selected && hovered else active && list.isEnabled
syncCells(value, show, list.isEnabled)
cellPane.isVisible = cells.isVisible
pill.isVisible = cells.isVisible
pill.background = if (active && list.isEnabled) UIUtil.getListBackground(true, true) else list.background
// Match the row's own background so the pill never paints a focused-selection highlight
// on a row that is not the focused selection (e.g. a hovered, unselected row).
pill.background = if (selected && list.isEnabled) UIUtil.getListBackground(true, active) else list.background
val height = bodyHeight
wrap.setPreferredSize(height?.let { Dimension(0, it) })
top.invalidate()
@@ -223,6 +225,8 @@ internal class ActiveListRenderer(
internal interface ActiveListActive {
fun active(): Boolean
fun hoveredIndex(): Int = -1
}
internal class ActiveListActionCell : JBLabel() {
@@ -50,6 +50,8 @@ internal class ActiveListView(
internal val list: JBList<ActiveListItem> = object : JBList<ActiveListItem>(model), ActiveListActive {
override fun active(): Boolean = popups > 0
override fun hoveredIndex(): Int = hovered
override fun getBackground(): Color {
if (surface == ActiveListSurface.ToolWindow) return activeListToolWindowBackground()
return super.getBackground() ?: UIUtil.getListBackground(false, false)
@@ -84,6 +86,7 @@ internal class ActiveListView(
private var filter = ""
private var press: Press? = null
private var popups = 0
private var hovered = -1
internal var onSelect: (() -> Unit)? = null
fun setEmptyText(text: String) {
@@ -106,7 +109,7 @@ internal class ActiveListView(
JComponent.WHEN_FOCUSED,
)
}
list.addMouseListener(object : MouseAdapter() {
val mouse = object : MouseAdapter() {
override fun mousePressed(e: MouseEvent) {
if (!UIUtil.isActionClick(e, MouseEvent.MOUSE_PRESSED, true)) return
list.requestFocusInWindow()
@@ -150,8 +153,26 @@ internal class ActiveListView(
onCell(hit.item.key, down.id)
e.consume()
}
})
override fun mouseMoved(e: MouseEvent) {
if (!cfg.hoverActions) return
val idx = list.locationToIndex(e.point)
.takeIf { it >= 0 && list.getCellBounds(it, it)?.contains(e.point) == true }
?: -1
setHovered(idx)
}
override fun mouseExited(e: MouseEvent) {
if (!cfg.hoverActions) return
setHovered(-1)
}
}
list.addMouseListener(mouse)
if (cfg.hoverActions) list.addMouseMotionListener(mouse)
list.addListSelectionListener { e: ListSelectionEvent ->
// Selection gates the hover-revealed action bar, so repaint the hovered row as soon as
// its selection flips instead of waiting for the next mouse move.
if (cfg.hoverActions) repaintRow(hovered)
if (!e.valueIsAdjusting) onSelect?.invoke()
}
list.addFocusListener(object : FocusAdapter() {
@@ -270,6 +291,7 @@ internal class ActiveListView(
@RequiresEdt
fun setBusy(value: Boolean) {
checkEdt()
if (value) setHovered(-1)
list.setPaintBusy(value)
if (list.isEnabled == !value) return
list.isEnabled = !value
@@ -326,6 +348,7 @@ internal class ActiveListView(
@RequiresEdt
private fun sync(prefer: String? = list.selectedValue?.key, at: Int? = null, scroll: Boolean = true) {
checkEdt()
setHovered(-1)
val q = filter.trim()
val rows = if (q.isBlank()) items else items.filter { matcher(q, it) }
model.replaceAll(rows)
@@ -337,6 +360,23 @@ internal class ActiveListView(
if (idx >= 0) choose(idx, scroll) else list.clearSelection()
}
@RequiresEdt
private fun setHovered(idx: Int) {
checkEdt()
if (hovered == idx) return
val old = hovered
hovered = idx
repaintRow(old)
repaintRow(idx)
}
@RequiresEdt
private fun repaintRow(idx: Int) {
checkEdt()
if (idx < 0) return
list.getCellBounds(idx, idx)?.let { list.repaint(it) }
}
@RequiresEdt
private fun syncCellHeight(rows: List<ActiveListItem>) {
checkEdt()
@@ -491,6 +491,110 @@ class SettingsListViewTest : BasePlatformTestCase() {
}
}
fun `test hover alone does not reveal cells on unselected row`() {
edt {
val cfg = ActiveListConfig.Equal.copy(hoverActions = true)
val view = ActiveListView("Empty", cfg) { _, _ -> }
view.update(listOf(item("with", "Alpha", null, ActiveListCell("edit", "Edit"))))
layout(view)
view.list.clearSelection()
hover(view, center(view.list.getCellBounds(0, 0)))
assertTrue(renderedCells(view, 0).isEmpty())
}
}
fun `test selection alone does not reveal cells without hover`() {
edt {
val cfg = ActiveListConfig.Equal.copy(hoverActions = true)
val view = ActiveListView("Empty", cfg) { _, _ -> }
view.update(listOf(item("with", "Alpha", null, ActiveListCell("edit", "Edit"))))
layout(view)
view.list.selectedIndex = 0
exit(view)
assertTrue(renderedCells(view, 0).isEmpty())
}
}
fun `test hover reveals cells only on the selected hovered row`() {
edt {
val cfg = ActiveListConfig.Equal.copy(hoverActions = true)
val view = ActiveListView("Empty", cfg) { _, _ -> }
view.update(listOf(
item("a", "Alpha", null, ActiveListCell("edit", "Edit")),
item("b", "Beta", null, ActiveListCell("delete", "Delete")),
))
layout(view)
view.list.selectedIndex = 0
hover(view, center(view.list.getCellBounds(0, 0)))
assertEquals(listOf("edit"), renderedCells(view, 0))
assertTrue(renderedCells(view, 1).isEmpty())
// Hovering the other, unselected row must not reveal its cells.
hover(view, center(view.list.getCellBounds(1, 1)))
assertTrue(renderedCells(view, 0).isEmpty())
assertTrue(renderedCells(view, 1).isEmpty())
exit(view)
assertTrue(renderedCells(view, 0).isEmpty())
}
}
fun `test selecting the hovered row reveals cells immediately`() {
edt {
val cfg = ActiveListConfig.Equal.copy(hoverActions = true)
val view = ActiveListView("Empty", cfg) { _, _ -> }
view.update(listOf(item("with", "Alpha", null, ActiveListCell("edit", "Edit"))))
layout(view)
view.list.clearSelection()
hover(view, center(view.list.getCellBounds(0, 0)))
assertTrue(renderedCells(view, 0).isEmpty())
view.list.selectedIndex = 0
assertEquals(listOf("edit"), renderedCells(view, 0))
}
}
fun `test hovered selected unfocused row uses unfocused selection background`() {
edt {
val cfg = ActiveListConfig.Equal.copy(hoverActions = true)
val row = item("with", "Alpha", null, ActiveListCell("edit", "Edit"))
val model = CollectionListModel<ActiveListItem>(listOf(row))
val list = object : JBList<ActiveListItem>(model), ActiveListActive {
override fun active(): Boolean = false
override fun hoveredIndex(): Int = 0
}
val renderer = ActiveListRenderer(model, cfg)
renderer.getListCellRendererComponent(list, row, 0, true, false)
assertEquals(listOf("edit"), actionCells(renderer).filter { it.isVisible }.map { it.cellId })
assertEquals(UIUtil.getListBackground(true, false), actionPill(renderer).background)
}
}
fun `test always visible action cells remain visible in hover action list`() {
edt {
val cfg = ActiveListConfig.Equal.copy(hoverActions = true)
val view = ActiveListView("Empty", cfg) { _, _ -> }
view.update(listOf(item(
"with",
"Alpha",
null,
ActiveListCell("level", "Allow", alwaysVisible = true),
ActiveListCell("edit", "Edit"),
)))
layout(view)
assertEquals(listOf("level"), renderedCells(view, 0))
}
}
fun `test renderer reuses action cells across updates`() {
edt {
val first = item(
@@ -674,6 +778,21 @@ class SettingsListViewTest : BasePlatformTestCase() {
private fun actionCells(root: java.awt.Component): List<ActiveListActionCell> =
components(root).filterIsInstance<ActiveListActionCell>()
private fun renderedCells(view: ActiveListView, idx: Int): List<String> {
val value = view.list.model.getElementAt(idx)
val bounds = view.list.getCellBounds(idx, idx)
val comp = view.list.cellRenderer.getListCellRendererComponent(
view.list,
value,
idx,
view.list.isSelectedIndex(idx),
true,
)
comp.setBounds(0, 0, view.list.width, bounds.height)
layout(comp as Container)
return actionCells(comp).filter { it.isVisible }.map { it.cellId }
}
private fun actionPill(root: java.awt.Component): JPanel {
val cell = actionCells(root).single()
return cell.parent.parent.parent as JPanel
@@ -704,6 +823,16 @@ class SettingsListViewTest : BasePlatformTestCase() {
fire(view.list, mouse(view, MouseEvent.MOUSE_RELEASED, point))
}
private fun hover(view: ActiveListView, point: Point) {
val event = event(view.list, point)
view.list.mouseMotionListeners.forEach { it.mouseMoved(event) }
}
private fun exit(view: ActiveListView) {
val event = event(view.list, Point(-1, -1), MouseEvent.MOUSE_EXITED)
view.list.mouseListeners.forEach { it.mouseExited(event) }
}
private fun mouse(view: ActiveListView, id: Int, point: Point, count: Int = 1) = MouseEvent(
view.list,
id,
@@ -716,9 +845,9 @@ class SettingsListViewTest : BasePlatformTestCase() {
MouseEvent.BUTTON1,
)
private fun event(list: javax.swing.JList<*>, point: Point) = MouseEvent(
private fun event(list: javax.swing.JList<*>, point: Point, id: Int = MouseEvent.MOUSE_MOVED) = MouseEvent(
list,
MouseEvent.MOUSE_MOVED,
id,
System.currentTimeMillis(),
0,
point.x,