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,