From aeeb8eb5d473c6d6ca78e496813c736b288858af Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 27 May 2026 13:00:13 -0400 Subject: [PATCH] feat(jetbrains): show running badges in session history --- .changeset/witty-cabin.md | 5 ++ .../kilocode/client/app/KiloSessionService.kt | 15 ++++ .../ai/kilocode/client/session/SessionUi.kt | 2 +- .../session/history/HistoryController.kt | 2 + .../session/history/HistoryListRenderer.kt | 40 +++++++++-- .../client/session/history/HistoryPanel.kt | 46 ++++++++++-- .../client/session/ui/EmptySessionPanel.kt | 70 +++++++++++++++++-- .../kotlin/ai/kilocode/client/ui/UiStyle.kt | 10 +++ .../client/session/SessionUiFactoryTest.kt | 3 +- .../session/history/HistoryControllerTest.kt | 51 ++++++++++++++ .../session/ui/EmptySessionPanelTest.kt | 43 +++++++++++- 11 files changed, 267 insertions(+), 20 deletions(-) create mode 100644 .changeset/witty-cabin.md diff --git a/.changeset/witty-cabin.md b/.changeset/witty-cabin.md new file mode 100644 index 00000000000..59daff5dd11 --- /dev/null +++ b/.changeset/witty-cabin.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Show running badges on active sessions in JetBrains recent and history lists. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt index 4f2fdcf05f9..8878a24db2e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt @@ -32,6 +32,11 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +internal data class SessionActivitySnapshot( + val active: Set, + val changed: Set, +) + /** * Project-level frontend service for session management. * @@ -85,6 +90,14 @@ class KiloSessionService internal constructor( } } + internal fun activity(previous: Set): SessionActivitySnapshot { + val active = statuses.value + .filterValues { it.type == "busy" } + .keys + .toSet() + return SessionActivitySnapshot(active, active xor previous) + } + suspend fun list(dir: String): SessionListDto { val result = call { list(dir) } _sessions.value = result.sessions @@ -229,3 +242,5 @@ class KiloSessionService internal constructor( suspend fun pendingQuestions(dir: String): List = call { pendingQuestions(dir) } } + +private infix fun Set.xor(other: Set): Set = (this - other) + (other - this) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index e4aed46e47d..8205311fb77 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -292,7 +292,7 @@ class SessionUi( } is SessionControllerEvent.ViewChanged.ShowRecents -> { - val panel = EmptySessionPanel(this, controller, event.recents) { manager?.showHistory() } + val panel = EmptySessionPanel(this, controller, sessions, event.recents) { manager?.showHistory() } scroll.show(panel.view) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryController.kt index 81a386fda99..f8f9027d6e7 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryController.kt @@ -60,6 +60,8 @@ class HistoryController( reloadCloud() } + internal fun activity(previous: Set) = sessions.activity(previous) + fun reloadLocal() { edt { local.start() } cs.launch { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryListRenderer.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryListRenderer.kt index 4aae232943a..444fe56c176 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryListRenderer.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryListRenderer.kt @@ -1,6 +1,8 @@ package ai.kilocode.client.session.history import ai.kilocode.client.session.ui.PickerRow +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.ui.FilledBadgeIcon import ai.kilocode.client.ui.UiStyle import com.intellij.icons.AllIcons import com.intellij.ui.GroupHeaderSeparator @@ -11,8 +13,10 @@ import com.intellij.util.ui.EmptyIcon import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil import java.awt.BorderLayout +import java.awt.FlowLayout import java.awt.Point import java.awt.Rectangle +import java.awt.Component import javax.swing.Icon import javax.swing.JList import javax.swing.JPanel @@ -24,6 +28,7 @@ private const val DELETE_AREA_WIDTH = 32 internal open class HistoryRenderer( private val model: HistoryModel, private val deletable: Boolean, + private val active: () -> Set, ) : JPanel(BorderLayout()), ListCellRenderer { companion object { private val icon: Icon = AllIcons.Actions.GC @@ -55,14 +60,19 @@ internal open class HistoryRenderer( add(sep, BorderLayout.NORTH) } private val title = SimpleColoredComponent() + private val badge = BadgeLabel() private val time = JBLabel() private val del = JBLabel().apply { horizontalAlignment = SwingConstants.CENTER verticalAlignment = SwingConstants.CENTER border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap()) } + private val head = JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply { + add(title) + add(badge) + } private val main = JPanel(BorderLayout()).apply { - add(title, BorderLayout.CENTER) + add(head, BorderLayout.CENTER) add(time, BorderLayout.EAST) } private val row = JPanel(BorderLayout()).apply { @@ -75,7 +85,7 @@ internal open class HistoryRenderer( isOpaque = true top.isOpaque = true row.border = JBUI.Borders.empty(UiStyle.Gap.lg(), UiStyle.Gap.lg(), UiStyle.Gap.lg(), UiStyle.Gap.lg()) - UiStyle.Components.transparent(row, main, title, time, del) + UiStyle.Components.transparent(row, main, head, title, badge, time, del) wrap.setContent(row) add(top, BorderLayout.NORTH) add(wrap, BorderLayout.CENTER) @@ -106,13 +116,35 @@ internal open class HistoryRenderer( ) time.text = value?.let(HistoryTime::relative).orEmpty() time.foreground = weak + badge.isVisible = value?.id in active() if (deletable) del.icon = if (selected) icon else empty top.invalidate() return this } + + internal fun runningVisible() = badge.isVisible + + private class BadgeLabel : JBLabel( + FilledBadgeIcon( + KiloBundle.message("session.part.tool.running"), + UiStyle.Colors.runningBadgeBg(), + UiStyle.Colors.runningBadgeFg(), + ) + ) { + init { + border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap()) + alignmentY = Component.CENTER_ALIGNMENT + } + } } -internal class LocalHistoryRenderer(model: HistoryModel) : HistoryRenderer(model, deletable = true) +internal class LocalHistoryRenderer( + model: HistoryModel, + active: () -> Set = { emptySet() }, +) : HistoryRenderer(model, deletable = true, active) -internal class CloudHistoryRenderer(model: HistoryModel) : HistoryRenderer(model, deletable = false) +internal class CloudHistoryRenderer( + model: HistoryModel, + active: () -> Set = { emptySet() }, +) : HistoryRenderer(model, deletable = false, active) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryPanel.kt index 7945be7ee2b..d19453a39b4 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryPanel.kt @@ -16,6 +16,7 @@ import com.intellij.openapi.actionSystem.ActionManager import com.intellij.openapi.actionSystem.ActionPlaces import com.intellij.openapi.actionSystem.DataProvider import com.intellij.openapi.util.Disposer +import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.ui.DocumentAdapter import com.intellij.ui.PopupHandler import com.intellij.ui.SearchTextField @@ -48,6 +49,7 @@ import javax.swing.JList import javax.swing.KeyStroke import javax.swing.ListSelectionModel import javax.swing.SwingUtilities +import javax.swing.Timer import javax.swing.event.DocumentEvent import javax.swing.event.ListDataEvent import javax.swing.event.ListDataListener @@ -60,6 +62,7 @@ class HistoryPanel( ) : BorderLayoutPanel(), Disposable, DataProvider { private val localSearch = search(controller.local) private val cloudSearch = search(controller.cloud) + private var active = emptySet() private val localList = localList() private val cloudList = cloudList() private val more = LoadMoreButton() @@ -80,6 +83,7 @@ class HistoryPanel( .setText(KiloBundle.message("history.tab.cloud")) .setForeSideComponent(back()) private var stale = false + private val timer = Timer(ACTIVITY_MS) { syncActivity() } private val tabs: JBTabs = JBTabsFactory.createTabs(null, this).apply { presentation.setSingleRow(true) presentation.setTabsPosition(JBTabsPosition.top) @@ -105,11 +109,14 @@ class HistoryPanel( } addHierarchyListener { e -> if (e.changeFlags and HierarchyEvent.SHOWING_CHANGED.toLong() == 0L) return@addHierarchyListener - if (isShowing && stale) { - refresh() + if (isShowing) { + syncActivity() + timer.start() + if (stale) refresh() return@addHierarchyListener } - if (!isShowing) stale = true + timer.stop() + stale = true } body.add(load, CARD_LOAD) body.add(tabs.component, CARD_TABS) @@ -215,7 +222,7 @@ class HistoryPanel( private fun localList() = JBList(controller.local).apply { selectionMode = ListSelectionModel.MULTIPLE_INTERVAL_SELECTION isFocusable = true - cellRenderer = LocalHistoryRenderer(controller.local) + cellRenderer = LocalHistoryRenderer(controller.local) { active } cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) emptyText.text = KiloBundle.message("history.empty") addMouseListener(object : MouseAdapter() { @@ -243,7 +250,7 @@ class HistoryPanel( private fun cloudList() = JBList(controller.cloud).apply { selectionMode = ListSelectionModel.SINGLE_SELECTION isFocusable = true - cellRenderer = CloudHistoryRenderer(controller.cloud) + cellRenderer = CloudHistoryRenderer(controller.cloud) { active } cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) emptyText.text = KiloBundle.message("history.empty") addMouseListener(object : MouseAdapter() { @@ -284,6 +291,22 @@ class HistoryPanel( repaint() } + @RequiresEdt + internal fun syncActivity() { + val snap = controller.activity(active) + active = snap.active + repaintRows(localList, controller.local, snap.changed) + repaintRows(cloudList, controller.cloud, snap.changed) + } + + private fun repaintRows(list: JBList, model: HistoryModel, ids: Set) { + if (ids.isEmpty()) return + model.visibleItems.forEachIndexed { index, item -> + if (item.id !in ids) return@forEachIndexed + list.getCellBounds(index, index)?.let(list::repaint) + } + } + private fun loading(): Boolean { if (controller.local.loaded || controller.cloud.loaded) return false return controller.local.loading || controller.cloud.loading @@ -418,6 +441,17 @@ class HistoryPanel( return items.indices.mapNotNull { HistoryRenderer.section(items, it) } } + internal fun runningBadgeVisible(index: Int): Boolean { + val list = activeList() + val item = list.model.getElementAt(index) ?: return false + @Suppress("UNCHECKED_CAST") + val renderer = list.cellRenderer as javax.swing.ListCellRenderer + @Suppress("UNCHECKED_CAST") + val typed = list as JList + val view = renderer.getListCellRendererComponent(typed, item, index, false, false) + return view is HistoryRenderer<*> && view.runningVisible() + } + internal fun repoOnlyVisible() = repoOnly.isVisible internal fun repoOnlySelected() = repoOnly.isSelected @@ -445,6 +479,7 @@ class HistoryPanel( } override fun dispose() { + timer.stop() controller.onRepoOnlyChanged = null } @@ -495,5 +530,6 @@ class HistoryPanel( private companion object { const val CARD_LOAD = "load" const val CARD_TABS = "tabs" + const val ACTIVITY_MS = 10_000 } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt index 339c6638a85..451ffa1a95a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.session.ui +import ai.kilocode.client.app.KiloSessionService import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.SessionRef import ai.kilocode.client.session.history.HistoryTime @@ -10,6 +11,7 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.controller.SessionController +import ai.kilocode.client.ui.FilledBadgeIcon import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.Align import ai.kilocode.client.ui.layout.HAlign @@ -20,6 +22,7 @@ import com.intellij.icons.AllIcons import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer import com.intellij.openapi.util.IconLoader +import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBList import com.intellij.util.ui.Centerizer @@ -31,9 +34,11 @@ import java.awt.BorderLayout import java.awt.Component import java.awt.Cursor import java.awt.Dimension +import java.awt.FlowLayout import java.awt.Graphics import java.awt.Graphics2D import java.awt.RenderingHints +import java.awt.event.HierarchyEvent import java.awt.event.MouseAdapter import java.awt.event.MouseEvent import java.awt.event.MouseMotionAdapter @@ -42,6 +47,7 @@ import javax.swing.JButton import javax.swing.JList import javax.swing.ListCellRenderer import javax.swing.ListSelectionModel +import javax.swing.Timer /** * Empty-session panel. @@ -52,6 +58,7 @@ import javax.swing.ListSelectionModel class EmptySessionPanel( parent: Disposable, private val controller: SessionController, + private val sessions: KiloSessionService, recents: List, private val history: () -> Unit = {}, ) : BorderLayoutPanel(), Disposable, SessionEditorStyleTarget { @@ -60,6 +67,8 @@ class EmptySessionPanel( private val model = DefaultListModel() private var hover = -1 private var style = SessionEditorStyle.current() + private var active = emptySet() + private val timer = Timer(ACTIVITY_MS) { syncActivity() } private val recentTitle = JBLabel(KiloBundle.message("session.empty.recent")).apply { foreground = UIUtil.getContextHelpForeground() @@ -124,6 +133,15 @@ class EmptySessionPanel( isOpaque = false applyStyle(SessionEditorStyle.current()) setSessions(recents) + addHierarchyListener { e -> + if (e.changeFlags and HierarchyEvent.SHOWING_CHANGED.toLong() == 0L) return@addHierarchyListener + if (isShowing) { + syncActivity() + timer.start() + return@addHierarchyListener + } + timer.stop() + } val gap = UiStyle.Gap.pad() layout = BorderLayout(0, gap) @@ -220,6 +238,21 @@ class EmptySessionPanel( } } + @RequiresEdt + internal fun syncActivity() { + val snap = sessions.activity(active) + active = snap.active + repaintRows(snap.changed) + } + + private fun repaintRows(ids: Set) { + if (ids.isEmpty()) return + repeat(model.size()) { index -> + if (model.getElementAt(index).id !in ids) return@repeat + list.getCellBounds(index, index)?.let(list::repaint) + } + } + private fun index(e: MouseEvent): Int { val idx = list.locationToIndex(e.point) if (idx < 0) return -1 @@ -230,12 +263,30 @@ class EmptySessionPanel( private inner class SessionRenderer : BorderLayoutPanel(), ListCellRenderer { private val title = JBLabel() + private val badge = JBLabel( + FilledBadgeIcon( + KiloBundle.message("session.part.tool.running"), + UiStyle.Colors.runningBadgeBg(), + UiStyle.Colors.runningBadgeFg(), + ) + ).apply { + border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap()) + } private val time = JBLabel() + private val head = BorderLayoutPanel().apply { + add(BorderLayoutPanel().apply { + layout = FlowLayout(FlowLayout.LEFT, 0, 0) + isOpaque = false + add(title) + add(badge) + }, BorderLayout.CENTER) + } init { layout = BorderLayout(UiStyle.Gap.pad(), 0) border = JBUI.Borders.empty(UiStyle.Gap.lg(), UiStyle.Gap.lg(), UiStyle.Gap.lg(), UiStyle.Gap.lg()) - add(title, BorderLayout.CENTER) + head.isOpaque = false + add(head, BorderLayout.CENTER) add(time, BorderLayout.EAST) } @@ -246,13 +297,14 @@ class EmptySessionPanel( selected: Boolean, focus: Boolean, ): Component { - val active = selected || hover == index - isOpaque = active - background = if (active) list.selectionBackground else list.background - title.foreground = if (active) list.selectionForeground else UIUtil.getLabelForeground() - time.foreground = if (active) list.selectionForeground else UIUtil.getContextHelpForeground() + val over = selected || hover == index + isOpaque = over + background = if (over) list.selectionBackground else list.background + title.foreground = if (over) list.selectionForeground else UIUtil.getLabelForeground() + time.foreground = if (over) list.selectionForeground else UIUtil.getContextHelpForeground() title.text = value?.let(::title) ?: "" time.text = value?.let(HistoryTime::relative) ?: "" + badge.isVisible = value?.id in active return this } } @@ -301,7 +353,7 @@ class EmptySessionPanel( } override fun dispose() { - // no-op + timer.stop() } override fun applyStyle(style: SessionEditorStyle) { @@ -315,4 +367,8 @@ class EmptySessionPanel( private fun welcomeHtml() = XmlStringUtil.wrapInHtml( "
${XmlStringUtil.escapeString(KiloBundle.message("session.empty.welcome"))}
" ) + + private companion object { + const val ACTIVITY_MS = 10_000 + } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt index 156d0760338..683acdc4f47 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt @@ -70,6 +70,16 @@ object UiStyle { /** Filled badge text color paired with [badgeBg]. */ fun badgeFg(): Color = JBColor(Color.BLACK, UIUtil.getLabelForeground()) + fun runningBadgeBg(): Color = JBColor.namedColor( + "Kilo.History.runningBadgeBackground", + JBColor(0xF5C542, 0x7A5A00), + ) + + fun runningBadgeFg(): Color = JBColor.namedColor( + "Kilo.History.runningBadgeForeground", + JBColor(Color.BLACK, Color.WHITE), + ) + /** Card border color shared across profile cards. */ fun cardBorder(): Color = JBColor.namedColor("Component.borderColor", JBColor.border()) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt index 9fe2e4ef305..c9d4eb34b06 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt @@ -72,7 +72,7 @@ class SessionUiFactoryTest : BasePlatformTestCase() { val rpc = session("ses_1") val ui = SessionUi(project, workspace, sessions, app, scope, manager = manager, workspaces = workspaces) val controller = controller(ui) - val panel = ai.kilocode.client.session.ui.EmptySessionPanel(testRootDisposable, controller, listOf(rpc)) + val panel = ai.kilocode.client.session.ui.EmptySessionPanel(testRootDisposable, controller, sessions, listOf(rpc)) panel.clickRecent(0) @@ -87,6 +87,7 @@ class SessionUiFactoryTest : BasePlatformTestCase() { val panel = ai.kilocode.client.session.ui.EmptySessionPanel( testRootDisposable, controller, + sessions, emptyList(), ) { manager.showHistory() } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/history/HistoryControllerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/history/HistoryControllerTest.kt index 74fab599b59..1c96a918da3 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/history/HistoryControllerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/history/HistoryControllerTest.kt @@ -12,6 +12,7 @@ import ai.kilocode.rpc.dto.CloudSessionDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto import ai.kilocode.rpc.dto.SessionDto +import ai.kilocode.rpc.dto.SessionStatusDto import ai.kilocode.rpc.dto.SessionTimeDto import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager @@ -115,6 +116,56 @@ class HistoryControllerTest : BasePlatformTestCase() { assertTrue(controller.local.items.isEmpty()) } + fun `test activity returns busy ids and changed ids`() { + rpc.statuses.value = mapOf( + "ses_busy" to SessionStatusDto("busy"), + "ses_idle" to SessionStatusDto("idle"), + "ses_retry" to SessionStatusDto("retry"), + "ses_offline" to SessionStatusDto("offline"), + ) + flush() + + val snap = sessions.activity(setOf("ses_old", "ses_busy")) + + assertEquals(setOf("ses_busy"), snap.active) + assertEquals(setOf("ses_old"), snap.changed) + } + + fun `test local history renderer shows running badge for active id`() { + val item = LocalHistoryItem(session("ses_1", "Running")) + val controller = controller() + controller.local.replace(listOf(item)) + val renderer = LocalHistoryRenderer(controller.local) { setOf("ses_1") } + + renderer.getListCellRendererComponent(javax.swing.JList(arrayOf(item)), item, 0, false, false) + + assertTrue(renderer.runningVisible()) + } + + fun `test cloud history renderer hides running badge for inactive id`() { + val item = CloudHistoryItem(cloud("cloud_1", "Cloud")) + val controller = controller() + controller.cloud.replace(listOf(item), null) + val renderer = CloudHistoryRenderer(controller.cloud) { emptySet() } + + renderer.getListCellRendererComponent(javax.swing.JList(arrayOf(item)), item, 0, false, false) + + assertFalse(renderer.runningVisible()) + } + + fun `test history panel sync updates running badges`() { + rpc.listed += session("ses_1", "Local One") + val panel = HistoryPanel(parent, controller()) + flush() + assertFalse(panel.runningBadgeVisible(0)) + + rpc.statuses.value = mapOf("ses_1" to SessionStatusDto("busy")) + flush() + panel.syncActivity() + + assertTrue(panel.runningBadgeVisible(0)) + } + fun `test panel filters and switches source`() { rpc.listed += session("ses_1", "Alpha") rpc.listed += session("ses_2", "Beta") diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt index ca717ded514..8ac21bb093e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt @@ -9,6 +9,7 @@ import ai.kilocode.client.session.history.HistoryTime import ai.kilocode.client.session.history.LocalHistoryItem import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.controller.SessionController +import ai.kilocode.client.ui.FilledBadgeIcon import ai.kilocode.client.testing.FakeAppRpcApi import ai.kilocode.client.testing.FakeSessionRpcApi import ai.kilocode.client.testing.FakeWorkspaceRpcApi @@ -17,6 +18,7 @@ import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto import ai.kilocode.rpc.dto.SessionDto +import ai.kilocode.rpc.dto.SessionStatusDto import ai.kilocode.rpc.dto.SessionTimeDto import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.ui.components.JBLabel @@ -25,6 +27,8 @@ import com.intellij.util.ui.components.BorderLayoutPanel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking import java.awt.BorderLayout import java.awt.Cursor @@ -34,6 +38,8 @@ class EmptySessionPanelTest : BasePlatformTestCase() { private lateinit var app: KiloAppService private lateinit var workspace: Workspace private lateinit var controller: SessionController + private lateinit var rpc: FakeSessionRpcApi + private lateinit var sessions: KiloSessionService private val opened = mutableListOf() override fun setUp() { @@ -46,10 +52,12 @@ class EmptySessionPanelTest : BasePlatformTestCase() { it.state.value = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.READY) }) workspace = workspaces.workspace("/test") + rpc = FakeSessionRpcApi() + sessions = KiloSessionService(project, scope, rpc) controller = SessionController( parent = testRootDisposable, ref = null, - sessions = KiloSessionService(project, scope, FakeSessionRpcApi()), + sessions = sessions, workspace = workspace, app = app, cs = scope, @@ -193,6 +201,28 @@ class EmptySessionPanelTest : BasePlatformTestCase() { assertEquals("Untitled", label?.text) } + fun `test renderer shows running badge for busy recent session`() { + val panel = panel(listOf(session("ses_1"))) + rpc.statuses.value = mapOf("ses_1" to SessionStatusDto("busy")) + flush() + panel.syncActivity() + + val cell = panel.rendererComponent(session("ses_1")) as BorderLayoutPanel + + assertTrue(runningVisible(cell)) + } + + fun `test renderer hides running badge for idle recent session`() { + val panel = panel(listOf(session("ses_1"))) + rpc.statuses.value = mapOf("ses_1" to SessionStatusDto("idle")) + flush() + panel.syncActivity() + + val cell = panel.rendererComponent(session("ses_1")) as BorderLayoutPanel + + assertFalse(runningVisible(cell)) + } + fun `test timestamp normalization handles seconds and milliseconds`() { assertEquals(1_700_000_000_000L, HistoryTime.millis(LocalHistoryItem(session("ses_1", 1_700_000_000)))) assertEquals(1_700_000_000_000L, HistoryTime.millis(LocalHistoryItem(session("ses_1", 1_700_000_000_000)))) @@ -209,7 +239,16 @@ class EmptySessionPanelTest : BasePlatformTestCase() { } private fun panel(recents: List = emptyList(), history: () -> Unit = {}) = - EmptySessionPanel(testRootDisposable, controller, recents, history) + EmptySessionPanel(testRootDisposable, controller, sessions, recents, history) + + private fun flush() = runBlocking { + delay(100) + UIUtil.dispatchAllInvocationEvents() + } + + private fun runningVisible(cell: BorderLayoutPanel): Boolean = UIUtil.uiTraverser(cell) + .filter(JBLabel::class.java) + .any { it.isVisible && it.icon is FilledBadgeIcon } private fun session(id: String, updated: Long = 2_000L, title: String = "Title $id") = SessionDto( id = id,