From aafde0bf7e1b3263aacbff3a9c476af89dc2929d Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 3 Aug 2026 15:16:54 -0400 Subject: [PATCH] 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. --- .changeset/hover-agent-manager-actions.md | 5 + .../client/agentManager/AgentManagerPanel.kt | 2 + .../worktree/WorktreeSessionEditorPanel.kt | 1 + .../client/ui/list/ActiveListModel.kt | 1 + .../client/ui/list/ActiveListRenderer.kt | 12 +- .../kilocode/client/ui/list/ActiveListView.kt | 44 +++++- .../settings/base/SettingsListViewTest.kt | 133 +++++++++++++++++- 7 files changed, 190 insertions(+), 8 deletions(-) create mode 100644 .changeset/hover-agent-manager-actions.md diff --git a/.changeset/hover-agent-manager-actions.md b/.changeset/hover-agent-manager-actions.md new file mode 100644 index 00000000000..24c735cec52 --- /dev/null +++ b/.changeset/hover-agent-manager-actions.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Show Agent Manager row actions only while hovering worktree and session rows. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt index 2e2da0bc4c6..6c2ce4ee101 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt @@ -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 -> diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanel.kt index c862d40efa1..942fc93a690 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanel.kt @@ -66,6 +66,7 @@ class WorktreeSessionEditorPanel( ActiveListRowHeight.EQUAL, description = false, selection = ListSelectionModel.MULTIPLE_INTERVAL_SELECTION, + hoverActions = true, ), surface = ActiveListSurface.ToolWindow, showSearch = false, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt index 685e97feca2..6172962d322 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt @@ -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) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt index 3b596117624..0f654391797 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt @@ -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() { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListView.kt index 59ff870ac49..b0471c556fc 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListView.kt @@ -50,6 +50,8 @@ internal class ActiveListView( internal val list: JBList = object : JBList(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) { checkEdt() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt index 01c0dddab1b..a285d5be084 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt @@ -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(listOf(row)) + val list = object : JBList(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 = components(root).filterIsInstance() + private fun renderedCells(view: ActiveListView, idx: Int): List { + 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,