feat(jetbrains): hover rename/delete + popover rename in session history

Mirror the worktree list in the JetBrains session history: reveal rename
and delete buttons on hover, rename via the shared inline popover instead
of a modal dialog, and advance the selection to the neighbouring session
when the selected one is deleted so the highlight moves naturally.

Extract the shared rename/delete action cells into ui/list/ActiveListActions
and adopt them across the history, worktree, and worktree-session lists to
avoid duplicating the cell ids and construction.
This commit is contained in:
kirillk
2026-08-05 13:20:02 -04:00
parent 271c121c39
commit 46eac53716
10 changed files with 168 additions and 182 deletions
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": minor
---
Reveal rename and delete buttons on hover in JetBrains session history, rename sessions through an inline popover instead of a modal dialog, and move the selection to the neighbouring session after deleting the selected one.
@@ -1,28 +1,12 @@
package ai.kilocode.client.actions
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.history.HistoryDataKeys
import ai.kilocode.client.session.history.title
import ai.kilocode.client.session.SessionManager
import com.intellij.openapi.actionSystem.ActionUpdateThread
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.project.Project
import com.intellij.openapi.ui.Messages
class RenameSessionAction : AnAction() {
/** Overridable in tests to avoid showing a real modal dialog. */
internal var input: (project: Project?, current: String) -> String? = { project, current ->
Messages.showInputDialog(
project,
KiloBundle.message("history.rename.prompt"),
KiloBundle.message("history.rename.title"),
null,
current,
null,
)
}
override fun getActionUpdateThread() = ActionUpdateThread.EDT
override fun update(e: AnActionEvent) {
@@ -35,26 +19,8 @@ class RenameSessionAction : AnAction() {
override fun actionPerformed(e: AnActionEvent) {
val selection = e.getData(HistoryDataKeys.SELECTION) ?: return
val controller = e.getData(HistoryDataKeys.CONTROLLER) ?: return
val rename = e.getData(HistoryDataKeys.RENAME) ?: return
val item = selection.selectedLocal.singleOrNull() ?: return
val current = title(item)
controller.requestRename()
val raw = input(e.project, current)
if (raw == null) {
controller.cancelRename("cancelled")
return
}
val newTitle = raw.trim()
if (newTitle.isBlank()) {
controller.cancelRename("blank")
return
}
if (newTitle == current) {
controller.cancelRename("unchanged")
return
}
controller.rename(item, newTitle)
rename(item)
}
}
@@ -14,6 +14,8 @@ import ai.kilocode.client.agentManager.worktree.worktreeSessionParams
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.SessionActivityKind
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.list.ACTIVE_LIST_DELETE_CELL
import ai.kilocode.client.ui.list.ACTIVE_LIST_RENAME_CELL
import ai.kilocode.client.ui.list.ActiveList
import ai.kilocode.client.ui.list.ActiveListBadge
import ai.kilocode.client.ui.list.ActiveListCell
@@ -22,6 +24,8 @@ import ai.kilocode.client.ui.list.ActiveListDeleteOptions
import ai.kilocode.client.ui.list.ActiveListItem
import ai.kilocode.client.ui.list.ActiveListSelection
import ai.kilocode.client.ui.list.ActiveListSurface
import ai.kilocode.client.ui.list.activeListDeleteCell
import ai.kilocode.client.ui.list.activeListRenameCell
import ai.kilocode.client.ui.list.activeListToolWindowBackground
import ai.kilocode.client.vfs.KiloVfsManager
import ai.kilocode.rpc.dto.RemoveWorktreeResultDto
@@ -73,8 +77,8 @@ class AgentManagerPanel(
showSearch = false,
onCell = { key, id ->
val item = item(key) ?: return@ActiveList
if (id == RENAME_CELL && renameable(item)) beginRename(item, id)
if (id == DELETE_CELL && deletable(item)) showDeletePopup(item, id)
if (id == ACTIVE_LIST_RENAME_CELL && renameable(item)) beginRename(item, id)
if (id == ACTIVE_LIST_DELETE_CELL && deletable(item)) showDeletePopup(item, id)
},
onOpen = { row, focus ->
val item = (row as? WorktreeRow)?.dto ?: return@ActiveList
@@ -392,25 +396,10 @@ class AgentManagerPanel(
}
override val cells: List<ActiveListCell>
get() = if (dto.main || pending) emptyList() else listOf(
ActiveListCell(
RENAME_CELL,
KiloBundle.message("worktree.rename.action"),
icon = AllIcons.Actions.Edit,
iconOnly = true,
),
ActiveListCell(
DELETE_CELL,
KiloBundle.message("worktree.delete.action"),
icon = AllIcons.Actions.GC,
iconOnly = true,
),
activeListRenameCell(KiloBundle.message("worktree.rename.action")),
activeListDeleteCell(KiloBundle.message("worktree.delete.action")),
)
}
private companion object {
const val RENAME_CELL = "rename"
const val DELETE_CELL = "delete"
}
}
internal fun worktreeDeletable(item: WorktreeDto?, pending: Boolean): Boolean = item != null && !item.main && !pending
@@ -8,6 +8,8 @@ import ai.kilocode.client.session.SessionRef
import ai.kilocode.client.session.history.HistorySection
import ai.kilocode.client.session.history.HistoryTime
import ai.kilocode.client.session.history.LocalHistoryItem
import ai.kilocode.client.ui.list.ACTIVE_LIST_DELETE_CELL
import ai.kilocode.client.ui.list.ACTIVE_LIST_RENAME_CELL
import ai.kilocode.client.ui.list.ActiveList
import ai.kilocode.client.ui.list.ActiveListBadge
import ai.kilocode.client.ui.list.ActiveListCell
@@ -18,6 +20,8 @@ import ai.kilocode.client.ui.list.ActiveListItem
import ai.kilocode.client.ui.list.ActiveListRowHeight
import ai.kilocode.client.ui.list.ActiveListSelection
import ai.kilocode.client.ui.list.ActiveListSurface
import ai.kilocode.client.ui.list.activeListDeleteCell
import ai.kilocode.client.ui.list.activeListRenameCell
import ai.kilocode.client.ui.list.activeListToolWindowBackground
import ai.kilocode.rpc.dto.SessionDto
import com.intellij.icons.AllIcons
@@ -72,8 +76,8 @@ class WorktreeSessionEditorPanel(
showSearch = false,
enter = { true },
onCell = { key, id ->
if (id == RENAME_CELL) beginRename(key, RENAME_CELL)
if (id == DELETE_CELL) confirmDelete(listOf(key), DELETE_CELL)
if (id == ACTIVE_LIST_RENAME_CELL) beginRename(key, ACTIVE_LIST_RENAME_CELL)
if (id == ACTIVE_LIST_DELETE_CELL) confirmDelete(listOf(key), ACTIVE_LIST_DELETE_CELL)
},
onOpen = { row, focus -> open(row, focus) },
)
@@ -341,24 +345,9 @@ class WorktreeSessionEditorPanel(
get() {
if (selectedKeys().size != 1) return emptyList()
return listOf(
ActiveListCell(
RENAME_CELL,
KiloBundle.message("worktree.session.rename.action"),
icon = AllIcons.Actions.Edit,
iconOnly = true,
),
ActiveListCell(
DELETE_CELL,
KiloBundle.message("worktree.session.delete.action"),
icon = AllIcons.Actions.GC,
iconOnly = true,
),
activeListRenameCell(KiloBundle.message("worktree.session.rename.action")),
activeListDeleteCell(KiloBundle.message("worktree.session.delete.action")),
)
}
}
private companion object {
const val RENAME_CELL = "rename"
const val DELETE_CELL = "delete"
}
}
@@ -13,4 +13,11 @@ data class HistorySelection(
object HistoryDataKeys {
val SELECTION: DataKey<HistorySelection> = DataKey.create("ai.kilocode.client.session.history.HistorySelection")
val CONTROLLER: DataKey<HistoryController> = DataKey.create("ai.kilocode.client.session.history.HistoryController")
/**
* Opens the inline rename popover anchored to a local session row. Provided by [HistoryPanel] so
* the context-menu and RenameElement-shortcut rename share the same balloon as the hover pencil
* instead of a modal dialog.
*/
val RENAME: DataKey<(LocalHistoryItem) -> Unit> = DataKey.create("ai.kilocode.client.session.history.HistoryRename")
}
@@ -9,6 +9,8 @@ import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.layout.HAlign
import ai.kilocode.client.ui.layout.VAlign
import ai.kilocode.client.ui.layout.align
import ai.kilocode.client.ui.list.ACTIVE_LIST_DELETE_CELL
import ai.kilocode.client.ui.list.ACTIVE_LIST_RENAME_CELL
import ai.kilocode.client.ui.list.ActiveList
import ai.kilocode.client.ui.list.ActiveListConfig
import ai.kilocode.client.ui.list.ActiveListDeleteOptions
@@ -221,9 +223,11 @@ class HistoryPanel(
showSearch = false,
openOnClick = false,
onCell = { key, id ->
if (id != HISTORY_DELETE_CELL) return@ActiveList
val item = localRows.firstOrNull { it.key == key }?.item ?: return@ActiveList
showDeletePopup(listOf(item), HISTORY_DELETE_CELL)
when (id) {
ACTIVE_LIST_RENAME_CELL -> beginRename(item, id)
ACTIVE_LIST_DELETE_CELL -> showDeletePopup(listOf(item), id)
}
},
onOpen = { row, _ -> activate(row) },
).apply {
@@ -274,15 +278,27 @@ class HistoryPanel(
restore(cloudList, cloudRows)
}
/**
* Rebuilds [list] while keeping selection stable. Surviving selected rows stay selected; when a
* selected row disappears (e.g. it was just deleted) selection slides to the row that took its
* slot — the following row, or the last row when the removed one was last — so the highlight
* moves naturally instead of vanishing. Selection clears only when nothing was selected or the
* list is now empty.
*/
private fun restore(list: ActiveList, rows: List<ActiveListItem>) {
val keys = list.selectedKeys()
val anchor = list.selectedIndex()
list.update(rows, ActiveListSelection.PreserveNoScroll)
val indices = keys.mapNotNull { key -> rows.indexOfFirst { it.key == key }.takeIf { it >= 0 } }.toIntArray()
if (indices.isEmpty()) {
list.clearSelection()
if (indices.isNotEmpty()) {
list.setSelectionIndices(indices)
return
}
list.setSelectionIndices(indices)
if (keys.isNotEmpty() && rows.isNotEmpty()) {
list.selectIndex(anchor.coerceIn(0, rows.size - 1))
return
}
list.clearSelection()
}
@RequiresEdt
@@ -320,6 +336,7 @@ class HistoryPanel(
override fun getData(dataId: String): Any? {
if (SessionManager.KEY.`is`(dataId)) return manager
if (HistoryDataKeys.CONTROLLER.`is`(dataId)) return controller
if (HistoryDataKeys.RENAME.`is`(dataId)) return { item: LocalHistoryItem -> beginRename(item) }
if (HistoryDataKeys.SELECTION.`is`(dataId)) {
val source = selectedSource()
val local = if (source == HistorySource.LOCAL) {
@@ -361,6 +378,21 @@ class HistoryPanel(
showDeletePopup(items)
}
/**
* Opens the inline rename popover anchored to [item]'s row (or its pencil cell), matching the
* worktree list. Committing sends the new title through the controller; the popover itself gates
* out blank and unchanged names, so no modal dialog is involved.
*/
private fun beginRename(item: LocalHistoryItem, cell: String? = null) {
controller.requestRename()
localList.rename(
item.id,
cell,
current = { title(item) },
commit = { _, name -> controller.rename(item, name) },
)
}
internal fun itemCount() = activeRows().size
internal fun selectedSource() = if (tabs.selectedInfo === cloudInfo) HistorySource.CLOUD else HistorySource.LOCAL
@@ -1,13 +1,11 @@
package ai.kilocode.client.session.history
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.SessionActivityKind
import ai.kilocode.client.ui.list.ActiveListBadge
import ai.kilocode.client.ui.list.ActiveListCell
import ai.kilocode.client.ui.list.ActiveListItem
import com.intellij.icons.AllIcons
internal const val HISTORY_DELETE_CELL = "delete"
import ai.kilocode.client.ui.list.activeListDeleteCell
import ai.kilocode.client.ui.list.activeListRenameCell
internal data class LocalHistoryRow(
val item: LocalHistoryItem,
@@ -28,14 +26,7 @@ internal data class LocalHistoryRow(
override val cells: List<ActiveListCell>
get() {
if (deleting) return emptyList()
return listOf(
ActiveListCell(
HISTORY_DELETE_CELL,
KiloBundle.message("common.delete"),
icon = AllIcons.Actions.GC,
iconOnly = true,
),
)
return listOf(activeListRenameCell(), activeListDeleteCell())
}
}
@@ -0,0 +1,27 @@
package ai.kilocode.client.ui.list
import ai.kilocode.client.plugin.KiloBundle
import com.intellij.icons.AllIcons
/**
* Standard hover action-cell ids and factories shared by the reveal-on-hover lists (the worktree
* list, the worktree-session editor list, and session history). Centralising them keeps the
* pencil/trash buttons — their ids, icons, and `iconOnly` treatment — identical across every list
* instead of each panel re-declaring `"rename"`/`"delete"` and rebuilding the same [ActiveListCell].
*/
internal const val ACTIVE_LIST_RENAME_CELL = "rename"
internal const val ACTIVE_LIST_DELETE_CELL = "delete"
internal fun activeListRenameCell(label: String = KiloBundle.message("common.rename")) = ActiveListCell(
ACTIVE_LIST_RENAME_CELL,
label,
icon = AllIcons.Actions.Edit,
iconOnly = true,
)
internal fun activeListDeleteCell(label: String = KiloBundle.message("common.delete")) = ActiveListCell(
ACTIVE_LIST_DELETE_CELL,
label,
icon = AllIcons.Actions.GC,
iconOnly = true,
)
@@ -3,7 +3,6 @@ package ai.kilocode.client.actions
import ai.kilocode.client.app.KiloSessionService
import ai.kilocode.client.app.KiloWorkspaceService
import ai.kilocode.client.app.Workspace
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.SessionManager
import ai.kilocode.client.session.SessionRef
import ai.kilocode.client.session.history.CloudHistoryItem
@@ -298,72 +297,20 @@ class HistorySessionActionsTest : BasePlatformTestCase() {
// ------ RenameSessionAction.actionPerformed ------
fun `test rename action calls controller with trimmed changed title`() {
rpc.listed += sessionDto("ses_1", "Original")
controller.reloadLocal()
flush()
val item = controller.local.items[0]
val action = RenameSessionAction().apply { input = { _, _ -> " Renamed " } }
val event = event(action, manager, selection(HistorySource.LOCAL, listOf(item)), controller)
fun `test rename action opens rename popover for single local selection`() {
val item = localItem("ses_1")
val renamed = mutableListOf<String>()
val action = RenameSessionAction()
val event = event(action, manager, selection(HistorySource.LOCAL, listOf(item)), controller) { renamed += it.id }
action.actionPerformed(event)
flush()
assertEquals(listOf(Triple("ses_1", "/test", "Renamed")), rpc.renames)
assertEquals(listOf("ses_1"), renamed)
}
fun `test rename action passes displayed current title to input`() {
rpc.listed += sessionDto("ses_1", "Original")
controller.reloadLocal()
flush()
val prompts = mutableListOf<String>()
val item = controller.local.items[0]
val action = RenameSessionAction().apply {
input = { _, current ->
prompts.add(current)
null
}
}
val event = event(action, manager, selection(HistorySource.LOCAL, listOf(item)), controller)
action.actionPerformed(event)
flush()
assertEquals(listOf("Original"), prompts)
assertTrue(rpc.renames.isEmpty())
}
fun `test rename action passes untitled fallback to input`() {
rpc.listed += sessionDto("ses_1", "")
controller.reloadLocal()
flush()
val prompts = mutableListOf<String>()
val item = controller.local.items[0]
val action = RenameSessionAction().apply {
input = { _, current ->
prompts.add(current)
null
}
}
val event = event(action, manager, selection(HistorySource.LOCAL, listOf(item)), controller)
action.actionPerformed(event)
flush()
assertEquals(listOf(KiloBundle.message("history.untitled")), prompts)
assertTrue(rpc.renames.isEmpty())
}
fun `test rename action ignores blank input`() {
rpc.listed += sessionDto("ses_1", "Original")
controller.reloadLocal()
flush()
val item = controller.local.items[0]
val action = RenameSessionAction().apply { input = { _, _ -> " " } }
fun `test rename action does nothing without a rename provider`() {
val item = localItem("ses_1")
val action = RenameSessionAction()
val event = event(action, manager, selection(HistorySource.LOCAL, listOf(item)), controller)
action.actionPerformed(event)
@@ -372,34 +319,15 @@ class HistorySessionActionsTest : BasePlatformTestCase() {
assertTrue(rpc.renames.isEmpty())
}
fun `test rename action ignores unchanged input`() {
rpc.listed += sessionDto("ses_1", "Original")
controller.reloadLocal()
flush()
val item = controller.local.items[0]
val action = RenameSessionAction().apply { input = { _, _ -> "Original" } }
val event = event(action, manager, selection(HistorySource.LOCAL, listOf(item)), controller)
fun `test rename action does nothing for multiple local selection`() {
val items = listOf(localItem("ses_1"), localItem("ses_2"))
val renamed = mutableListOf<String>()
val action = RenameSessionAction()
val event = event(action, manager, selection(HistorySource.LOCAL, items), controller) { renamed += it.id }
action.actionPerformed(event)
flush()
assertTrue(rpc.renames.isEmpty())
}
fun `test rename action ignores null input`() {
rpc.listed += sessionDto("ses_1", "Original")
controller.reloadLocal()
flush()
val item = controller.local.items[0]
val action = RenameSessionAction().apply { input = { _, _ -> null } }
val event = event(action, manager, selection(HistorySource.LOCAL, listOf(item)), controller)
action.actionPerformed(event)
flush()
assertTrue(rpc.renames.isEmpty())
assertTrue(renamed.isEmpty())
}
fun `test frontend descriptor registers history actions`() {
@@ -426,6 +354,7 @@ class HistorySessionActionsTest : BasePlatformTestCase() {
manager: SessionManager?,
selection: HistorySelection,
ctrl: HistoryController,
rename: ((LocalHistoryItem) -> Unit)? = null,
): AnActionEvent {
val presentation = Presentation().apply { copyFrom(action.templatePresentation) }
val context = DataContext { id ->
@@ -434,6 +363,7 @@ class HistorySessionActionsTest : BasePlatformTestCase() {
SessionManager.KEY.`is`(id) -> manager
HistoryDataKeys.SELECTION.`is`(id) -> selection
HistoryDataKeys.CONTROLLER.`is`(id) -> ctrl
HistoryDataKeys.RENAME.`is`(id) -> rename
else -> null
}
}
@@ -12,6 +12,8 @@ import ai.kilocode.client.testing.FakeWorkspaceRpcApi
import ai.kilocode.client.testing.TestCoroutines
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.HoverIcon
import ai.kilocode.client.ui.list.ACTIVE_LIST_DELETE_CELL
import ai.kilocode.client.ui.list.ACTIVE_LIST_RENAME_CELL
import ai.kilocode.client.ui.layout.Align
import ai.kilocode.rpc.dto.CloudSessionDto
import ai.kilocode.rpc.dto.KiloWorkspaceStateDto
@@ -199,14 +201,13 @@ class HistoryControllerTest : BasePlatformTestCase() {
assertTrue(row.badges.isEmpty())
}
fun `test local history rows use trailing time and delete cell`() {
fun `test local history rows use trailing time and rename and delete cells`() {
val item = LocalHistoryItem(session("ses_1", "Long ".repeat(80)))
val row = localHistoryRows(listOf(item), HistoryActivitySnapshot()) { false }[0]
val cell = row.cells.single()
assertEquals(HistoryTime.relative(item), row.trailing)
assertEquals(HISTORY_DELETE_CELL, cell.id)
assertTrue(cell.iconOnly)
assertEquals(listOf(ACTIVE_LIST_RENAME_CELL, ACTIVE_LIST_DELETE_CELL), row.cells.map { it.id })
assertTrue(row.cells.all { it.iconOnly })
}
fun `test deleting local history rows hide actions and badges`() {
@@ -631,6 +632,55 @@ class HistoryControllerTest : BasePlatformTestCase() {
assertEquals(javax.swing.ListSelectionModel.SINGLE_SELECTION, panel.listSelectionMode())
}
// ------ Selection advances naturally on delete ------
fun `test deleting the selected session advances selection to the next row`() {
rpc.listed += session("ses_a", "A", 3.0)
rpc.listed += session("ses_b", "B", 2.0)
rpc.listed += session("ses_c", "C", 1.0)
val controller = controller()
val panel = HistoryPanel(parent, controller)
flush()
panel.select(1)
controller.delete(controller.local.items[1])
flush()
assertEquals(1, panel.selectedIndex())
assertEquals("C", panel.titleText(1))
}
fun `test deleting the last selected session falls back to the previous row`() {
rpc.listed += session("ses_a", "A", 3.0)
rpc.listed += session("ses_b", "B", 2.0)
val controller = controller()
val panel = HistoryPanel(parent, controller)
flush()
panel.select(1)
controller.delete(controller.local.items[1])
flush()
assertEquals(0, panel.selectedIndex())
assertEquals("A", panel.titleText(0))
}
fun `test deleting a background session keeps the current selection`() {
rpc.listed += session("ses_a", "A", 3.0)
rpc.listed += session("ses_b", "B", 2.0)
rpc.listed += session("ses_c", "C", 1.0)
val controller = controller()
val panel = HistoryPanel(parent, controller)
flush()
panel.select(0)
controller.delete(controller.local.items[2])
flush()
assertEquals(0, panel.selectedIndex())
assertEquals("A", panel.titleText(0))
}
// ------ Rename failure and directory selection ------
fun `test rename failure keeps original title and sets error`() {