diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorManager.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorManager.kt index 18b2a5821d7..01e3ada9c7e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorManager.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorManager.kt @@ -11,6 +11,8 @@ import ai.kilocode.client.session.SessionManager import ai.kilocode.client.session.SessionRef import ai.kilocode.client.session.SessionUi import ai.kilocode.client.session.SessionUiFactory +import ai.kilocode.client.session.history.HistoryTime +import ai.kilocode.client.session.history.LocalHistoryItem import ai.kilocode.client.util.UiTimerSource import ai.kilocode.client.util.UiTimers import ai.kilocode.rpc.dto.SessionDto @@ -118,6 +120,7 @@ open class WorktreeSessionEditorManager( val key = currentKey() val names = active.associateWith(::title) deleting.addAll(active) + val target = if (key in active) next(key) else null onListChanged?.invoke() active.forEach { id -> val name = names[id] ?: title(id) @@ -130,8 +133,7 @@ open class WorktreeSessionEditorManager( } active.forEach(::forceSession) if (key in active) { - val next = latest() - if (next != null) openSession(SessionRef.Local(next)) else newSession() + if (target != null) openSession(SessionRef.Local(target)) else newSession() } } @@ -158,6 +160,16 @@ open class WorktreeSessionEditorManager( .maxByOrNull { it.time.updated } } + @RequiresEdt + private fun next(key: String?): SessionDto? { + val rows = HistoryTime.sorted((0 until list.model.size).map { LocalHistoryItem(list.model.getElementAt(it)) }) + .map { it.session } + val idx = rows.indexOfFirst { it.id == key } + if (idx < 0) return rows.firstOrNull { it.id !in deleting } + return rows.drop(idx + 1).firstOrNull { it.id !in deleting } + ?: rows.take(idx).asReversed().firstOrNull { it.id !in deleting } + } + @RequiresEdt private fun title(id: String): String { return (0 until list.model.size) 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 1484826e9e3..0fcb764d867 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 @@ -258,7 +258,7 @@ class WorktreeSessionEditorPanel( override val section: String get() = HistoryTime.title(HistorySection.TODAY) } - private data class SessionRow( + private inner class SessionRow( val session: SessionDto, val kind: SessionActivityKind?, override val deleting: Boolean = false, @@ -272,12 +272,15 @@ class WorktreeSessionEditorPanel( override val section: String get() = HistoryTime.title(HistoryTime.section(item)) override val search: String get() = listOf(session.title, session.id, session.directory).joinToString(" ") override val cells: List - get() = listOf(ActiveListCell( - DELETE_CELL, - KiloBundle.message("worktree.session.delete.action"), - icon = AllIcons.Actions.GC, - iconOnly = true, - )) + get() { + if (selectedKeys().size != 1) return emptyList() + return listOf(ActiveListCell( + DELETE_CELL, + KiloBundle.message("worktree.session.delete.action"), + icon = AllIcons.Actions.GC, + iconOnly = true, + )) + } } private companion object { 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 4fa3f86b76a..e6af457d2a1 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 @@ -28,6 +28,7 @@ import java.awt.event.MouseAdapter import java.awt.event.MouseEvent import javax.swing.JComponent import javax.swing.KeyStroke +import javax.swing.ListSelectionModel import javax.swing.Scrollable import javax.swing.SwingConstants import javax.swing.event.ListSelectionEvent @@ -101,12 +102,14 @@ internal class ActiveListView( if (!UIUtil.isActionClick(e, MouseEvent.MOUSE_PRESSED, true)) return list.requestFocusInWindow() press = null + if (selection(e)) return val hit = hit(e) ?: return press = Press(hit.item.key, hit.id ?: return) } override fun mouseClicked(e: MouseEvent) { if (e.clickCount == 1 && UIUtil.isActionClick(e, MouseEvent.MOUSE_CLICKED, true)) { + if (selection(e)) return val hit = hit(e, enabled = false) ?: return if (hit.id != null) return if (hit.item.deleting) return @@ -126,6 +129,10 @@ internal class ActiveListView( override fun mouseReleased(e: MouseEvent) { if (!UIUtil.isActionClick(e, MouseEvent.MOUSE_RELEASED, true)) return + if (selection(e)) { + press = null + return + } val down = press ?: return press = null val hit = hit(e) ?: return @@ -447,6 +454,11 @@ internal class ActiveListView( private fun enter(): Boolean = AdvancedSettings.getBoolean(ENTER_FOCUS) + private fun selection(e: MouseEvent): Boolean { + if (list.selectionMode == ListSelectionModel.SINGLE_SELECTION) return false + return e.isShiftDown || e.isMetaDown || e.isControlDown + } + override fun getScrollableTracksViewportWidth() = true override fun getScrollableTracksViewportHeight() = false diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorManagerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorManagerTest.kt index 1ee4deca915..d0efb12c1d1 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorManagerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorManagerTest.kt @@ -24,6 +24,8 @@ import ai.kilocode.rpc.dto.SessionTimeDto import com.intellij.openapi.ui.TestDialog import com.intellij.openapi.ui.TestDialogManager import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.registry.Registry +import com.intellij.openapi.util.registry.RegistryKeyDescriptor import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.util.ui.UIUtil import kotlinx.coroutines.CompletableDeferred @@ -58,6 +60,7 @@ class WorktreeSessionEditorManagerTest : BasePlatformTestCase() { it.state.value = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.READY) }) workspace = workspaces.workspace(DIR) + useInactiveDisposeTimeout() } override fun tearDown() { @@ -159,6 +162,25 @@ class WorktreeSessionEditorManagerTest : BasePlatformTestCase() { assertEquals(listOf(first.id to DIR), rpc.deletes.toList()) } + fun `test deleting middle shown session falls back to next visible session`() { + val top = session("ses_top", updated = 3.0) + val mid = session("ses_mid", updated = 2.0) + val bottom = session("ses_bottom", updated = 1.0) + rpc.listed += top + rpc.listed += mid + rpc.listed += bottom + val manager = manager() + edt { manager.start() } + flush() + edt { manager.openSession(SessionRef.Local(mid), focus = false) } + + edt { manager.deleteSessions(listOf(mid.id)) } + pump() + flush() + + assertEquals(listOf(DIR to top.id, DIR to mid.id, DIR to bottom.id), created) + } + fun `test delete marks session deleting then removes on success`() { val gate = CompletableDeferred() rpc.deleteGate = gate @@ -251,6 +273,24 @@ class WorktreeSessionEditorManagerTest : BasePlatformTestCase() { assertTrue(edt(block)) } + private fun useInactiveDisposeTimeout() { + Registry.mutateContributedKeys { + it + (TIMEOUT to RegistryKeyDescriptor( + TIMEOUT, + "Milliseconds before hidden session UI is disposed after switching away.", + "180000", + false, + false, + null, + null, + )) + } + Disposer.register(testRootDisposable) { + Registry.mutateContributedKeys { it - TIMEOUT } + } + Registry.get(TIMEOUT).setValue(60_000, testRootDisposable) + } + private fun pump() { com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait { UIUtil.dispatchAllInvocationEvents() @@ -266,6 +306,7 @@ class WorktreeSessionEditorManagerTest : BasePlatformTestCase() { private companion object { const val DIR = "/repo/.kilo/worktrees/feature-x" + const val TIMEOUT = "kilo.session.inactive.disposeTimeoutMs" } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanelTest.kt index e3ccda21379..3aac4483282 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanelTest.kt @@ -179,6 +179,30 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() { assertEquals(listOf(false), manager.focuses) } + fun `test modified row click preserves multi selection`() { + rpc.listed += session("ses_1", 1.0) + rpc.listed += session("ses_2", 2.0) + edt { controller.reload() } + flush() + + val list = edt { UIUtil.findComponentOfType(panel, JBList::class.java)!! } + edt { + list.setSize(400, 100) + list.doLayout() + list.selectedIndices = intArrayOf(0, 1) + val bounds = list.getCellBounds(1, 1) + val x = bounds.x + 8 + val y = bounds.y + bounds.height / 2 + fire(list, MouseEvent(list, MouseEvent.MOUSE_CLICKED, System.currentTimeMillis(), InputEvent.SHIFT_DOWN_MASK, x, y, 1, false, MouseEvent.BUTTON1)) + assertEquals(listOf(0, 1), list.selectedIndices.toList()) + fire(list, MouseEvent(list, MouseEvent.MOUSE_CLICKED, System.currentTimeMillis(), InputEvent.META_DOWN_MASK, x, y, 1, false, MouseEvent.BUTTON1)) + assertEquals(listOf(0, 1), list.selectedIndices.toList()) + } + + assertTrue(manager.refs.isEmpty()) + assertTrue(manager.focuses.isEmpty()) + } + fun `test row click ignores deleting session`() { manager.deletingIds += "ses_1" rpc.listed += session("ses_1", 1.0) @@ -247,6 +271,21 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() { assertEquals(listOf("ses_2", "ses_1"), manager.deleted) } + fun `test multi select hides row delete cells`() { + rpc.listed += session("ses_1", 1.0) + rpc.listed += session("ses_2", 2.0) + edt { controller.reload() } + flush() + + edt { panel.selectSessions(listOf("ses_1")) } + assertEquals(listOf(DELETE_CELL), row("ses_1").cells.map { it.id }) + + edt { panel.selectSessions(listOf("ses_1", "ses_2")) } + + assertTrue(row("ses_1").cells.isEmpty()) + assertTrue(row("ses_2").cells.isEmpty()) + } + fun `test delete action skips deleting selected sessions`() { manager.deletingIds += "ses_1" rpc.listed += session("ses_1", 1.0) @@ -386,5 +425,6 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() { private companion object { const val DIR = "/repo/.kilo/worktrees/feature-x" + const val DELETE_CELL = "delete" } }