feat(jetbrains): show running badges in session history

This commit is contained in:
kirillk
2026-05-27 13:00:13 -04:00
parent 444a066965
commit aeeb8eb5d4
11 changed files with 267 additions and 20 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Show running badges on active sessions in JetBrains recent and history lists.
@@ -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<String>,
val changed: Set<String>,
)
/**
* Project-level frontend service for session management.
*
@@ -85,6 +90,14 @@ class KiloSessionService internal constructor(
}
}
internal fun activity(previous: Set<String>): 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<QuestionRequestDto> =
call { pendingQuestions(dir) }
}
private infix fun <T> Set<T>.xor(other: Set<T>): Set<T> = (this - other) + (other - this)
@@ -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)
}
@@ -60,6 +60,8 @@ class HistoryController(
reloadCloud()
}
internal fun activity(previous: Set<String>) = sessions.activity(previous)
fun reloadLocal() {
edt { local.start() }
cs.launch {
@@ -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<T : HistoryItem>(
private val model: HistoryModel<T>,
private val deletable: Boolean,
private val active: () -> Set<String>,
) : JPanel(BorderLayout()), ListCellRenderer<T> {
companion object {
private val icon: Icon = AllIcons.Actions.GC
@@ -55,14 +60,19 @@ internal open class HistoryRenderer<T : HistoryItem>(
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<T : HistoryItem>(
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<T : HistoryItem>(
)
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<LocalHistoryItem>) : HistoryRenderer<LocalHistoryItem>(model, deletable = true)
internal class LocalHistoryRenderer(
model: HistoryModel<LocalHistoryItem>,
active: () -> Set<String> = { emptySet() },
) : HistoryRenderer<LocalHistoryItem>(model, deletable = true, active)
internal class CloudHistoryRenderer(model: HistoryModel<CloudHistoryItem>) : HistoryRenderer<CloudHistoryItem>(model, deletable = false)
internal class CloudHistoryRenderer(
model: HistoryModel<CloudHistoryItem>,
active: () -> Set<String> = { emptySet() },
) : HistoryRenderer<CloudHistoryItem>(model, deletable = false, active)
@@ -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<String>()
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 <T : HistoryItem> repaintRows(list: JBList<T>, model: HistoryModel<T>, ids: Set<String>) {
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<HistoryItem>
@Suppress("UNCHECKED_CAST")
val typed = list as JList<HistoryItem>
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
}
}
@@ -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<SessionDto>,
private val history: () -> Unit = {},
) : BorderLayoutPanel(), Disposable, SessionEditorStyleTarget {
@@ -60,6 +67,8 @@ class EmptySessionPanel(
private val model = DefaultListModel<LocalHistoryItem>()
private var hover = -1
private var style = SessionEditorStyle.current()
private var active = emptySet<String>()
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<String>) {
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<LocalHistoryItem> {
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(
"<div style='text-align:center'>${XmlStringUtil.escapeString(KiloBundle.message("session.empty.welcome"))}</div>"
)
private companion object {
const val ACTIVITY_MS = 10_000
}
}
@@ -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())
@@ -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() }
@@ -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")
@@ -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<String>()
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<SessionDto> = 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,