From b16944ce2409b3503313cc871e2e0d40b925e230 Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 27 May 2026 16:09:20 -0400 Subject: [PATCH] feat(jetbrains): keep history activity metadata fresh --- .changeset/jetbrains-active-badges.md | 5 + .changeset/jetbrains-history-refresh.md | 5 + .../kilocode/client/app/KiloSessionService.kt | 15 +- .../client/session/SessionActivityKind.kt | 32 ++ .../kilocode/client/session/SessionManager.kt | 6 + .../client/session/SessionSidePanelManager.kt | 57 +++- .../ai/kilocode/client/session/SessionUi.kt | 49 ++- .../session/controller/SessionController.kt | 196 ++++++++---- .../session/controller/SessionUpdateQueue.kt | 5 + .../history/HistoryActivitySnapshot.kt | 13 + .../session/history/HistoryController.kt | 2 +- .../session/history/HistoryListRenderer.kt | 41 ++- .../client/session/history/HistoryPanel.kt | 41 ++- .../client/session/ui/EmptySessionPanel.kt | 38 ++- .../ai/kilocode/client/ui/FilledBadgeIcon.kt | 2 +- .../kotlin/ai/kilocode/client/ui/UiStyle.kt | 10 + .../resources/kilo.jetbrains.frontend.xml | 5 + .../resources/messages/KiloBundle.properties | 4 + .../session/SessionSidePanelManagerTest.kt | 285 ++++++++++++++++++ .../client/session/SessionUiFactoryTest.kt | 6 +- .../controller/SessionUpdateQueueTest.kt | 79 +++++ .../history/HistoryActivitySnapshotTest.kt | 69 +++++ .../session/history/HistoryControllerTest.kt | 90 +++++- .../session/ui/EmptySessionPanelTest.kt | 91 +++++- 24 files changed, 1003 insertions(+), 143 deletions(-) create mode 100644 .changeset/jetbrains-active-badges.md create mode 100644 .changeset/jetbrains-history-refresh.md create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionActivityKind.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryActivitySnapshot.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/history/HistoryActivitySnapshotTest.kt diff --git a/.changeset/jetbrains-active-badges.md b/.changeset/jetbrains-active-badges.md new file mode 100644 index 00000000000..89e19d5f707 --- /dev/null +++ b/.changeset/jetbrains-active-badges.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Show question, permission, plan, and login-required badges for active JetBrains sessions in recent and history lists. diff --git a/.changeset/jetbrains-history-refresh.md b/.changeset/jetbrains-history-refresh.md new file mode 100644 index 00000000000..bf87fff84ec --- /dev/null +++ b/.changeset/jetbrains-history-refresh.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Refresh JetBrains history and recent-session rows when active session titles change, and keep pending inactive sessions alive when switching views. 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 8878a24db2e..87d1a05c747 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 @@ -4,6 +4,7 @@ package ai.kilocode.client.app import ai.kilocode.log.ChatLogSummary import ai.kilocode.rpc.KiloSessionRpcApi +import ai.kilocode.client.session.SessionActivityKind import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.CloudSessionListDto import ai.kilocode.rpc.dto.ConfigUpdateDto @@ -32,11 +33,6 @@ 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. * @@ -90,13 +86,10 @@ class KiloSessionService internal constructor( } } - internal fun activity(previous: Set): SessionActivitySnapshot { - val active = statuses.value + internal fun activity(): Map = + statuses.value .filterValues { it.type == "busy" } - .keys - .toSet() - return SessionActivitySnapshot(active, active xor previous) - } + .mapValues { SessionActivityKind.RUNNING } suspend fun list(dir: String): SessionListDto { val result = call { list(dir) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionActivityKind.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionActivityKind.kt new file mode 100644 index 00000000000..d1c9aeff9f6 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionActivityKind.kt @@ -0,0 +1,32 @@ +package ai.kilocode.client.session + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.ui.UiStyle +import java.awt.Color + +enum class SessionActivityKind { + RUNNING, + LOGIN_REQUIRED, + PERMISSION, + PLAN, + QUESTION, + ; + + fun label(): String = when (this) { + RUNNING -> KiloBundle.message("session.part.tool.running") + LOGIN_REQUIRED -> KiloBundle.message("history.badge.loginRequired") + PERMISSION -> KiloBundle.message("history.badge.permission") + PLAN -> KiloBundle.message("history.badge.plan") + QUESTION -> KiloBundle.message("history.badge.question") + } + + fun bg(): Color = when (this) { + RUNNING -> UiStyle.Colors.runningBadgeBg() + LOGIN_REQUIRED, PERMISSION, PLAN, QUESTION -> UiStyle.Colors.activityBadgeBg() + } + + fun fg(): Color = when (this) { + RUNNING -> UiStyle.Colors.runningBadgeFg() + LOGIN_REQUIRED, PERMISSION, PLAN, QUESTION -> UiStyle.Colors.activityBadgeFg() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionManager.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionManager.kt index 62d8ec94074..0f67cd97ac0 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionManager.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionManager.kt @@ -14,6 +14,12 @@ interface SessionManager { fun openSession(ref: SessionRef) + fun activity(): Map = emptyMap() + + fun titles(): Map = emptyMap() + + fun activityChanged() {} + fun openSession(session: SessionDto) { openSession(SessionRef.Local(session)) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt index 118c2e87d24..6d0cf70b7f0 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt @@ -12,7 +12,9 @@ import com.intellij.openapi.application.ModalityState import com.intellij.openapi.components.service import com.intellij.openapi.project.Project import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.registry.Registry import com.intellij.openapi.wm.IdeFocusManager +import com.intellij.util.concurrency.annotations.RequiresEdt import kotlinx.coroutines.cancel import java.awt.BorderLayout import javax.swing.JComponent @@ -25,6 +27,7 @@ class SessionSidePanelManager( service().create(project, workspace, manager, ref) }, private val resolve: (String) -> Workspace = { dir -> service().workspace(dir) }, + private val status: () -> Map = { project.service().activity() }, private val history: ((Disposable, (SessionRef) -> Unit, (String) -> Unit) -> JComponent)? = null, ) : SessionManager, Disposable { val component: JPanel = object : JPanel(BorderLayout()), DataProvider { @@ -62,6 +65,30 @@ class SessionSidePanelManager( show(ui) } + @RequiresEdt + override fun activity(): Map { + val base = status() + val live = all.mapNotNull { ui -> + val id = ui.id ?: return@mapNotNull null + val kind = ui.activityKind() ?: return@mapNotNull null + id to kind + }.toMap() + return base + live + } + + @RequiresEdt + override fun titles(): Map = all.mapNotNull { ui -> + val id = ui.id ?: return@mapNotNull null + val title = ui.title() ?: return@mapNotNull null + id to title + }.toMap() + + @RequiresEdt + override fun activityChanged() { + (panel as? HistoryPanel)?.syncActivity() + current?.syncActivity() + } + private fun create(ref: SessionRef): SessionUi { val workspace = when (ref) { is SessionRef.Local -> ref.session?.directory?.let(resolve) ?: root @@ -76,8 +103,9 @@ class SessionSidePanelManager( } override fun showHistory() { - register(current) - release(current) + val active = current + register(active) + release(active) val cached = panel val view = cached ?: createHistory().also { panel = it } if (cached != null && view is HistoryPanel) view.refresh() @@ -128,11 +156,7 @@ class SessionSidePanelManager( private fun removeSession(id: String) { val ui = opened.remove(id) ?: return - opened.entries.removeIf { it.value === ui } - all.remove(ui) - if (current === ui) current = null - if (latest === ui) latest = null - Disposer.dispose(ui) + disposeUi(ui) } private fun show(ui: SessionUi) { @@ -155,14 +179,31 @@ class SessionSidePanelManager( private fun release(ui: SessionUi?) { if (ui == null) return - if (ui.cacheKey != null) { + if (ui.cacheKey == null) { + disposeUi(ui) + return + } + if (!disposeInactiveUi()) { register(ui) return } + if (ui.canDisposeInactive()) { + disposeUi(ui) + return + } + register(ui) + } + + private fun disposeUi(ui: SessionUi) { + opened.entries.removeIf { it.value === ui } all.remove(ui) + if (current === ui) current = null + if (latest === ui) latest = null Disposer.dispose(ui) } + private fun disposeInactiveUi() = Registry.`is`("kilo.session.inactive.dispose", false) + override fun dispose() { val items = all.toList() opened.clear() 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 8205311fb77..980f73157b9 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 @@ -130,6 +130,7 @@ class SessionUi( private lateinit var prompt: PromptPanel private lateinit var load: LoadingPanel private lateinit var migrationOverlay: MigrationOverlayPanel + private var empty: EmptySessionPanel? = null private var modalFocus: (() -> JComponent)? = null private var style = SessionEditorStyle.current() @@ -162,6 +163,31 @@ class SessionUi( internal fun currentStyle() = style + @RequiresEdt + internal fun canDisposeInactive(): Boolean = controller.model.state is SessionState.Idle + + @RequiresEdt + internal fun activityKind(): SessionActivityKind? = when (val state = controller.model.state) { + is SessionState.Idle, + is SessionState.Loading, + is SessionState.Busy, + is SessionState.Retry, + is SessionState.Offline, + is SessionState.Error -> null + is SessionState.LoginRequired -> SessionActivityKind.LOGIN_REQUIRED + is SessionState.AwaitingPermission -> SessionActivityKind.PERMISSION + is SessionState.AwaitingQuestion -> + SessionActivityKind.PLAN.takeIf { state.question.items.any { it.planFollowup() } } ?: SessionActivityKind.QUESTION + } + + @RequiresEdt + internal fun title(): String? = controller.model.session?.title?.takeIf { it.isNotBlank() } + + @RequiresEdt + internal fun syncActivity() { + empty?.syncActivity() + } + val defaultFocusedComponent: JComponent get() { modalFocus?.invoke()?.let { return it } return prompt.defaultFocusedComponent @@ -288,15 +314,25 @@ class SessionUi( } is SessionControllerEvent.ViewChanged.ShowProgress -> { + empty = null scroll.show(progressBody) } is SessionControllerEvent.ViewChanged.ShowRecents -> { - val panel = EmptySessionPanel(this, controller, sessions, event.recents) { manager?.showHistory() } + val panel = EmptySessionPanel( + this, + controller, + event.recents, + history = { manager?.showHistory() }, + activity = { manager?.activity() ?: sessions.activity() }, + titles = { manager?.titles().orEmpty() }, + ) + empty = panel scroll.show(panel.view) } is SessionControllerEvent.ViewChanged.ShowSession -> { + empty = null scroll.show(messageBody) } @@ -318,6 +354,8 @@ class SessionUi( when (event) { is SessionModelEvent.StateChanged -> onStateChanged(event.state) + is SessionModelEvent.SessionUpdated -> onSessionUpdated() + is SessionModelEvent.TurnAdded, is SessionModelEvent.TurnUpdated, is SessionModelEvent.ContentAdded, @@ -331,7 +369,6 @@ class SessionUi( is SessionModelEvent.ContentRemoved, is SessionModelEvent.DiffUpdated, is SessionModelEvent.TodosUpdated, - is SessionModelEvent.SessionUpdated, is SessionModelEvent.HeaderUpdated, is SessionModelEvent.Compacted, is SessionModelEvent.Cleared -> Unit @@ -437,9 +474,14 @@ class SessionUi( prompt.setBusy(state.isBusy()) load.setState(state) scroll.show(body(state)) + manager?.activityChanged() refresh() } + private fun onSessionUpdated() { + manager?.activityChanged() + } + private fun refresh() { scroll.refresh() root.revalidate() @@ -469,3 +511,6 @@ class SessionUi( } private fun variantTitle(value: String): String = value.replaceFirstChar { it.titlecase() } + +private fun ai.kilocode.client.session.model.QuestionItem.planFollowup() = + questionKey == "plan.followup.question" || headerKey == "plan.followup.header" diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt index f84dfd6fcd1..9ca8d56807f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt @@ -113,7 +113,8 @@ class SessionController( flushMs, ::handle, condense, - ref != null + ref != null, + ::handleHidden, ) { sid ?: ref?.key ?: "pending" } private var disposed = false @@ -884,16 +885,7 @@ class SessionController( is ChatEventDto.SessionCreated -> adoptFollowup(event.info) is ChatEventDto.Error -> { - partType = null - tool = null - if (isPaidModelAuthRequired(event.error)) { - loginRetry = retryPrompt() - showSession() - model.setState(SessionState.LoginRequired(KiloBundle.message("session.login.required.description"))) - } else { - val msg = event.error?.message ?: event.error?.type ?: KiloBundle.message("session.error.unknown") - model.setState(SessionState.Error(msg, event.error?.type)) - } + error(event, true) } is ChatEventDto.MessageRemoved -> { @@ -901,79 +893,33 @@ class SessionController( } is ChatEventDto.PermissionAsked -> { - if (autoApprove) { - approve(event.request) - return - } - val perm = toPermission(event.request) - model.setState(SessionState.AwaitingPermission(perm)) + asked(event) } is ChatEventDto.PermissionReplied -> { - val current = model.state - if (current is SessionState.AwaitingPermission && current.permission.id == event.requestID) { - model.setState(SessionState.Busy(KiloBundle.message("session.status.considering"))) - } + replied(event) } is ChatEventDto.QuestionAsked -> { - model.setState(SessionState.AwaitingQuestion(toQuestion(event.request))) + asked(event) } is ChatEventDto.QuestionReplied -> { - val current = model.state - if (current is SessionState.AwaitingQuestion && current.question.id == event.requestID) { - model.setState(SessionState.Busy(KiloBundle.message("session.status.considering"))) - } + replied(event) } is ChatEventDto.QuestionRejected -> { - val current = model.state - if (current is SessionState.AwaitingQuestion && current.question.id == event.requestID) { - model.setState(SessionState.Idle) - } + rejected(event) } is ChatEventDto.SessionStatusChanged -> { - val state = when (event.status.type) { - "idle" -> { - val current = model.state - if (current is SessionState.LoginRequired) return - SessionState.Idle - } - "busy" -> { - val current = model.state - if (current is SessionState.Idle || current is SessionState.Error) - SessionState.Busy(KiloBundle.message("session.status.considering")) - else return // already in a more specific phase - } - "retry" -> SessionState.Retry( - message = event.status.message ?: "", - attempt = event.status.attempt ?: 0, - next = event.status.next ?: 0L, - ) - "offline" -> SessionState.Offline( - message = event.status.message ?: "", - requestId = event.status.requestID ?: "", - ) - else -> return - } - model.setState(state) + status(event.status) } is ChatEventDto.SessionUpdated -> model.setSession(event.session) is ChatEventDto.SessionIdle -> { - // Treat session.idle as an explicit signal to return to Idle. - // Only apply if we're not in a more specific non-terminal state. - val current = model.state - if (current !is SessionState.Error - && current !is SessionState.AwaitingPermission - && current !is SessionState.AwaitingQuestion - && current !is SessionState.LoginRequired - ) { - model.setState(SessionState.Idle) - } + idle() } is ChatEventDto.SessionCompacted -> model.markCompacted() @@ -982,6 +928,128 @@ class SessionController( } } + private fun handleHidden(event: ChatEventDto): Boolean = when (event) { + is ChatEventDto.Error, + is ChatEventDto.PermissionAsked, + is ChatEventDto.PermissionReplied, + is ChatEventDto.QuestionAsked, + is ChatEventDto.QuestionReplied, + is ChatEventDto.QuestionRejected, + is ChatEventDto.SessionStatusChanged, + is ChatEventDto.SessionUpdated, + is ChatEventDto.SessionIdle -> { + edt { + if (disposed) return@edt + updateModel { handleMetadata(event) } + } + true + } + else -> false + } + + private fun handleMetadata(event: ChatEventDto) { + LOG.debug { ChatLogSummary.event(event) } + when (event) { + is ChatEventDto.Error -> error(event, false) + is ChatEventDto.PermissionAsked -> asked(event) + is ChatEventDto.PermissionReplied -> replied(event) + is ChatEventDto.QuestionAsked -> asked(event) + is ChatEventDto.QuestionReplied -> replied(event) + is ChatEventDto.QuestionRejected -> rejected(event) + is ChatEventDto.SessionStatusChanged -> status(event.status) + is ChatEventDto.SessionUpdated -> model.setSession(event.session) + is ChatEventDto.SessionIdle -> idle() + else -> Unit + } + } + + private fun error(event: ChatEventDto.Error, reveal: Boolean) { + partType = null + tool = null + if (isPaidModelAuthRequired(event.error)) { + loginRetry = retryPrompt() + if (reveal) showSession() + model.setState(SessionState.LoginRequired(KiloBundle.message("session.login.required.description"))) + return + } + val msg = event.error?.message ?: event.error?.type ?: KiloBundle.message("session.error.unknown") + model.setState(SessionState.Error(msg, event.error?.type)) + } + + private fun asked(event: ChatEventDto.PermissionAsked) { + if (autoApprove) { + approve(event.request) + return + } + val perm = toPermission(event.request) + model.setState(SessionState.AwaitingPermission(perm)) + } + + private fun replied(event: ChatEventDto.PermissionReplied) { + val current = model.state + if (current is SessionState.AwaitingPermission && current.permission.id == event.requestID) { + model.setState(SessionState.Busy(KiloBundle.message("session.status.considering"))) + } + } + + private fun asked(event: ChatEventDto.QuestionAsked) { + model.setState(SessionState.AwaitingQuestion(toQuestion(event.request))) + } + + private fun replied(event: ChatEventDto.QuestionReplied) { + val current = model.state + if (current is SessionState.AwaitingQuestion && current.question.id == event.requestID) { + model.setState(SessionState.Busy(KiloBundle.message("session.status.considering"))) + } + } + + private fun rejected(event: ChatEventDto.QuestionRejected) { + val current = model.state + if (current is SessionState.AwaitingQuestion && current.question.id == event.requestID) { + model.setState(SessionState.Idle) + } + } + + private fun status(dto: SessionStatusDto) { + val state = when (dto.type) { + "idle" -> { + val current = model.state + if (current is SessionState.LoginRequired) return + SessionState.Idle + } + "busy" -> { + val current = model.state + if (current is SessionState.Idle || current is SessionState.Error) + SessionState.Busy(KiloBundle.message("session.status.considering")) + else return // already in a more specific phase + } + "retry" -> SessionState.Retry( + message = dto.message ?: "", + attempt = dto.attempt ?: 0, + next = dto.next ?: 0L, + ) + "offline" -> SessionState.Offline( + message = dto.message ?: "", + requestId = dto.requestID ?: "", + ) + else -> return + } + model.setState(state) + } + + private fun idle() { + // Treat session.idle as an explicit signal to return to Idle. + // Only apply if we're not in a more specific non-terminal state. + val current = model.state + if (current !is SessionState.Error + && current !is SessionState.AwaitingPermission + && current !is SessionState.AwaitingQuestion + && current !is SessionState.LoginRequired + ) { + model.setState(SessionState.Idle) + } + } + private fun retryPrompt(): PromptDto? { val msg = model.messages().lastOrNull { it.info.role == "user" } ?: return null return PromptDto( diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionUpdateQueue.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionUpdateQueue.kt index 7c35f9dde2e..c5b63c2f2a2 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionUpdateQueue.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionUpdateQueue.kt @@ -23,6 +23,7 @@ internal class SessionUpdateQueue( private val fire: (List) -> Unit, private val condense: Boolean = true, hold: Boolean, + private val hidden: (ChatEventDto) -> Boolean = { false }, private val sid: () -> String, ) : Disposable { companion object { @@ -59,6 +60,10 @@ internal class SessionUpdateQueue( } fun enqueue(event: ChatEventDto) { + if (!visible.get() && hidden(event)) { + LOG.debug { "${ChatLogSummary.sid(sid())} enqueue hidden=true visible=false" } + return + } val size = synchronized(lock) { pending.add(event) pending.size diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryActivitySnapshot.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryActivitySnapshot.kt new file mode 100644 index 00000000000..e916578638b --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryActivitySnapshot.kt @@ -0,0 +1,13 @@ +package ai.kilocode.client.session.history + +import ai.kilocode.client.session.SessionActivityKind + +internal data class HistoryActivitySnapshot( + val activity: Map = emptyMap(), + val titles: Map = emptyMap(), +) { + fun changed(next: HistoryActivitySnapshot): Set = + (activity.keys + next.activity.keys + titles.keys + next.titles.keys).filterTo(mutableSetOf()) { + activity[it] != next.activity[it] || titles[it] != next.titles[it] + } +} 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 f8f9027d6e7..d790daf3901 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,7 +60,7 @@ class HistoryController( reloadCloud() } - internal fun activity(previous: Set) = sessions.activity(previous) + internal fun activity() = sessions.activity() fun reloadLocal() { edt { local.start() } 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 444fe56c176..794c80fcba5 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,7 +1,7 @@ package ai.kilocode.client.session.history import ai.kilocode.client.session.ui.PickerRow -import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.SessionActivityKind import ai.kilocode.client.ui.FilledBadgeIcon import ai.kilocode.client.ui.UiStyle import com.intellij.icons.AllIcons @@ -28,7 +28,8 @@ private const val DELETE_AREA_WIDTH = 32 internal open class HistoryRenderer( private val model: HistoryModel, private val deletable: Boolean, - private val active: () -> Set, + private val activity: () -> Map, + private val titles: () -> Map = { emptyMap() }, ) : JPanel(BorderLayout()), ListCellRenderer { companion object { private val icon: Icon = AllIcons.Actions.GC @@ -80,6 +81,7 @@ internal open class HistoryRenderer( if (deletable) add(del, BorderLayout.EAST) } private val wrap = PickerRow() + private var text = "" init { isOpaque = true @@ -110,13 +112,14 @@ internal open class HistoryRenderer( top.isVisible = sep.caption != null title.clear() + text = value?.let { titles()[it.id] ?: title(it) }.orEmpty() title.append( - value?.let(::title).orEmpty(), + text, SimpleTextAttributes(SimpleTextAttributes.STYLE_BOLD, fg), ) time.text = value?.let(HistoryTime::relative).orEmpty() time.foreground = weak - badge.isVisible = value?.id in active() + badge.setKind(value?.id?.let(activity()::get)) if (deletable) del.icon = if (selected) icon else empty top.invalidate() @@ -125,26 +128,34 @@ internal open class HistoryRenderer( internal fun runningVisible() = badge.isVisible - private class BadgeLabel : JBLabel( - FilledBadgeIcon( - KiloBundle.message("session.part.tool.running"), - UiStyle.Colors.runningBadgeBg(), - UiStyle.Colors.runningBadgeFg(), - ) - ) { + internal fun badgeText() = badge.kind?.label() + + internal fun titleText() = text + + private class BadgeLabel : JBLabel() { + var kind: SessionActivityKind? = null + private set + init { border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap()) alignmentY = Component.CENTER_ALIGNMENT } + + fun setKind(value: SessionActivityKind?) { + kind = value + isVisible = value != null + icon = value?.let { FilledBadgeIcon(it.label(), it.bg(), it.fg()) } + } } } internal class LocalHistoryRenderer( model: HistoryModel, - active: () -> Set = { emptySet() }, -) : HistoryRenderer(model, deletable = true, active) + activity: () -> Map = { emptyMap() }, + titles: () -> Map = { emptyMap() }, +) : HistoryRenderer(model, deletable = true, activity, titles) internal class CloudHistoryRenderer( model: HistoryModel, - active: () -> Set = { emptySet() }, -) : HistoryRenderer(model, deletable = false, active) + activity: () -> Map = { emptyMap() }, +) : HistoryRenderer(model, deletable = false, activity) 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 d19453a39b4..34147ce19e0 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 @@ -62,7 +62,7 @@ class HistoryPanel( ) : BorderLayoutPanel(), Disposable, DataProvider { private val localSearch = search(controller.local) private val cloudSearch = search(controller.cloud) - private var active = emptySet() + private var snapshot = HistoryActivitySnapshot() private val localList = localList() private val cloudList = cloudList() private val more = LoadMoreButton() @@ -222,7 +222,7 @@ class HistoryPanel( private fun localList() = JBList(controller.local).apply { selectionMode = ListSelectionModel.MULTIPLE_INTERVAL_SELECTION isFocusable = true - cellRenderer = LocalHistoryRenderer(controller.local) { active } + cellRenderer = LocalHistoryRenderer(controller.local, { snapshot.activity }, { snapshot.titles }) cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) emptyText.text = KiloBundle.message("history.empty") addMouseListener(object : MouseAdapter() { @@ -250,7 +250,7 @@ class HistoryPanel( private fun cloudList() = JBList(controller.cloud).apply { selectionMode = ListSelectionModel.SINGLE_SELECTION isFocusable = true - cellRenderer = CloudHistoryRenderer(controller.cloud) { active } + cellRenderer = CloudHistoryRenderer(controller.cloud) { snapshot.activity } cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) emptyText.text = KiloBundle.message("history.empty") addMouseListener(object : MouseAdapter() { @@ -293,10 +293,14 @@ class HistoryPanel( @RequiresEdt internal fun syncActivity() { - val snap = controller.activity(active) - active = snap.active - repaintRows(localList, controller.local, snap.changed) - repaintRows(cloudList, controller.cloud, snap.changed) + val next = HistoryActivitySnapshot( + activity = manager?.activity() ?: controller.activity(), + titles = manager?.titles().orEmpty(), + ) + val changed = snapshot.changed(next) + snapshot = next + repaintRows(localList, controller.local, changed) + repaintRows(cloudList, controller.cloud, changed) } private fun repaintRows(list: JBList, model: HistoryModel, ids: Set) { @@ -442,14 +446,31 @@ class HistoryPanel( } internal fun runningBadgeVisible(index: Int): Boolean { + return badgeText(index) != null + } + + internal fun badgeText(index: Int): String? { val list = activeList() - val item = list.model.getElementAt(index) ?: return false + val item = list.model.getElementAt(index) ?: return null @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() + if (view !is HistoryRenderer<*>) return null + return view.badgeText() + } + + internal fun titleText(index: Int): String? { + val list = activeList() + val item = list.model.getElementAt(index) ?: return null + @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) + if (view !is HistoryRenderer<*>) return null + return view.titleText() } internal fun repoOnlyVisible() = repoOnly.isVisible @@ -530,6 +551,6 @@ class HistoryPanel( private companion object { const val CARD_LOAD = "load" const val CARD_TABS = "tabs" - const val ACTIVITY_MS = 10_000 + const val ACTIVITY_MS = 3_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 451ffa1a95a..7f8283a7067 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,8 +1,9 @@ package ai.kilocode.client.session.ui -import ai.kilocode.client.app.KiloSessionService import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.SessionActivityKind import ai.kilocode.client.session.SessionRef +import ai.kilocode.client.session.history.HistoryActivitySnapshot import ai.kilocode.client.session.history.HistoryTime import ai.kilocode.client.session.history.LocalHistoryItem import ai.kilocode.client.session.history.itemAt @@ -58,16 +59,17 @@ import javax.swing.Timer class EmptySessionPanel( parent: Disposable, private val controller: SessionController, - private val sessions: KiloSessionService, recents: List, private val history: () -> Unit = {}, + private val activity: () -> Map = { emptyMap() }, + private val titles: () -> Map = { emptyMap() }, ) : BorderLayoutPanel(), Disposable, SessionEditorStyleTarget { val view: Align = align(HAlign.CENTER, VAlign.CENTER) private val model = DefaultListModel() private var hover = -1 private var style = SessionEditorStyle.current() - private var active = emptySet() + private var snapshot = HistoryActivitySnapshot() private val timer = Timer(ACTIVITY_MS) { syncActivity() } private val recentTitle = JBLabel(KiloBundle.message("session.empty.recent")).apply { @@ -240,9 +242,10 @@ class EmptySessionPanel( @RequiresEdt internal fun syncActivity() { - val snap = sessions.activity(active) - active = snap.active - repaintRows(snap.changed) + val next = HistoryActivitySnapshot(activity(), titles()) + val changed = snapshot.changed(next) + snapshot = next + repaintRows(changed) } private fun repaintRows(ids: Set) { @@ -263,13 +266,7 @@ 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 { + private val badge = JBLabel().apply { border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap()) } private val time = JBLabel() @@ -302,11 +299,16 @@ class EmptySessionPanel( 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) ?: "" + title.text = value?.let { snapshot.titles[it.id] ?: title(it) } ?: "" time.text = value?.let(HistoryTime::relative) ?: "" - badge.isVisible = value?.id in active + setBadge(value?.id?.let(snapshot.activity::get)) return this } + + private fun setBadge(kind: SessionActivityKind?) { + badge.isVisible = kind != null + badge.icon = kind?.let { FilledBadgeIcon(it.label(), it.bg(), it.fg()) } + } } private inner class ShowHistoryButton : JButton(KiloBundle.message("session.showHistory"), AllIcons.Vcs.History) { @@ -369,6 +371,10 @@ class EmptySessionPanel( ) private companion object { - const val ACTIVITY_MS = 10_000 + const val ACTIVITY_MS = 3_000 } } + +private fun Map.changed(next: Map) = (keys + next.keys).filterTo(mutableSetOf()) { + this[it] != next[it] +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/FilledBadgeIcon.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/FilledBadgeIcon.kt index 7c740167225..11e0e961a6d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/FilledBadgeIcon.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/FilledBadgeIcon.kt @@ -11,7 +11,7 @@ import java.awt.font.FontRenderContext import javax.swing.Icon internal class FilledBadgeIcon( - private val text: String, + internal val text: String, private val bg: Color, private val fg: Color, ) : Icon { 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 683acdc4f47..3f9b680cf40 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 @@ -80,6 +80,16 @@ object UiStyle { JBColor(Color.BLACK, Color.WHITE), ) + fun activityBadgeBg(): Color = JBColor.namedColor( + "Kilo.History.activityBadgeBackground", + JBUI.CurrentTheme.Link.Foreground.ENABLED, + ) + + fun activityBadgeFg(): Color = JBColor.namedColor( + "Kilo.History.activityBadgeForeground", + 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/main/resources/kilo.jetbrains.frontend.xml b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml index b578f8cf792..e801b3b2c1a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml +++ b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml @@ -39,6 +39,11 @@ defaultValue="150" restartRequired="false" overrides="false"/> + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index 8899b29dea8..d6021b3d515 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -157,6 +157,10 @@ history.rename.title=Rename Session history.rename.prompt=New session name: history.cloud.load.more=Load more history.cloud.repo.only=Only this repository +history.badge.loginRequired=Login Required +history.badge.permission=Permission +history.badge.plan=Plan +history.badge.question=Question history.group.today=Today history.group.yesterday=Yesterday history.group.week=This Week diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt index 4d7abbc2326..154fa2f0eb6 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt @@ -4,19 +4,34 @@ import ai.kilocode.client.app.KiloAppService 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.history.HistoryController +import ai.kilocode.client.session.history.HistoryDataKeys import ai.kilocode.client.session.history.HistoryPanel +import ai.kilocode.client.session.history.LocalHistoryItem +import ai.kilocode.client.session.model.Permission +import ai.kilocode.client.session.model.PermissionMeta +import ai.kilocode.client.session.model.Question +import ai.kilocode.client.session.model.QuestionItem +import ai.kilocode.client.session.model.SessionState import ai.kilocode.client.testing.FakeAppRpcApi import ai.kilocode.client.testing.FakeSessionRpcApi import ai.kilocode.client.testing.FakeWorkspaceRpcApi +import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.CloudSessionDto import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import ai.kilocode.rpc.dto.QuestionInfoDto +import ai.kilocode.rpc.dto.QuestionRequestDto import ai.kilocode.rpc.dto.SessionDto +import ai.kilocode.rpc.dto.SessionStatusDto import ai.kilocode.rpc.dto.SessionTimeDto import com.intellij.openapi.actionSystem.DataProvider 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 kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob @@ -120,6 +135,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { } fun `test opening same existing session reuses component`() { + disableInactiveDispose() val manager = manager() val session = session("ses_1") @@ -134,6 +150,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { } fun `test prompted blank session is reused from recents`() { + disableInactiveDispose() val manager = manager() manager.newSession() val first = active(manager) @@ -180,6 +197,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { } fun `test inactive sessions keep queued style updates`() { + disableInactiveDispose() val manager = manager() manager.openSession(session("ses_1")) val first = active(manager) as SessionUi @@ -193,6 +211,115 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { assertSame(style, first.currentStyle()) } + fun `test dispose inactive flag disposes previous session ui`() { + enableDisposeInactiveUi() + val manager = manager() + + manager.openSession(session("ses_1")) + val first = active(manager) + settle() + manager.openSession(session("ses_2")) + + assertFalse(ui.contains(first)) + assertEquals(listOf("/test" to "ses_1", "/test" to "ses_2"), created) + } + + fun `test dispose inactive flag recreates reopened session ui`() { + enableDisposeInactiveUi() + val manager = manager() + + manager.openSession(session("ses_1")) + val first = active(manager) + settle() + manager.openSession(session("ses_2")) + manager.openSession(session("ses_1")) + + assertNotSame(first, active(manager)) + assertEquals(listOf("/test" to "ses_1", "/test" to "ses_2", "/test" to "ses_1"), created) + } + + fun `test dispose inactive flag keeps pending session for history overlays`() { + enableDisposeInactiveUi() + val history = JLabel("History") + val manager = manager(history = { _, _, _ -> history }) + + manager.openSession(session("ses_1")) + val first = active(manager) + com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait { + first.controller().model.setState(SessionState.AwaitingQuestion(question(plan = false))) + } + manager.showHistory() + + assertSame(history, manager.component.getComponent(0)) + assertTrue(ui.contains(first)) + } + + fun `test history overlays update with inactive dispose enabled`() { + enableDisposeInactiveUi() + lateinit var history: HistoryPanel + val manager = manager(history = { parent, _, _ -> + val controller = HistoryController(sessions, workspace, scope) + controller.local.replace(listOf(LocalHistoryItem(session("ses_1", "/test", "Stored")))) + HistoryPanel(parent, controller, manager = parent as SessionManager).also { history = it } + }) + + manager.openSession(session("ses_1", "/test", "Stored")) + val first = active(manager) + settle() + com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait { + first.controller().model.setState(SessionState.AwaitingQuestion(question(plan = false))) + } + manager.showHistory() + settle() + val controller = history.getData(HistoryDataKeys.CONTROLLER.name) as HistoryController + controller.local.replace(listOf(LocalHistoryItem(session("ses_1", "/test", "Stored")))) + assertEquals(1, history.itemCount()) + + com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait { + first.controller().model.setSession(session("ses_1", "/test", "Live")) + } + settle() + history.syncActivity() + + assertTrue(ui.contains(first)) + assertEquals("Live", history.titleText(0)) + assertEquals(KiloBundle.message("history.badge.question"), history.badgeText(0)) + } + + fun `test history overlays update from hidden session stream metadata`() { + disableInactiveDispose() + lateinit var history: HistoryPanel + val manager = manager(history = { parent, _, _ -> + val controller = HistoryController(sessions, workspace, scope) + controller.local.replace(listOf(LocalHistoryItem(session("ses_1", "/test", "Stored")))) + HistoryPanel(parent, controller, manager = parent as SessionManager).also { history = it } + }) + + manager.openSession(session("ses_1", "/test", "Stored")) + settle() + manager.showHistory() + settle() + val controller = history.getData(HistoryDataKeys.CONTROLLER.name) as HistoryController + controller.local.replace(listOf(LocalHistoryItem(session("ses_1", "/test", "Stored")))) + assertEquals(1, history.itemCount()) + + kotlinx.coroutines.runBlocking { + rpc.events.emit(ChatEventDto.QuestionAsked("ses_1", rpcQuestion("q1"))) + } + settle() + + assertEquals(mapOf("ses_1" to SessionActivityKind.QUESTION), manager.activity()) + assertEquals(KiloBundle.message("history.badge.question"), history.badgeText(0)) + + kotlinx.coroutines.runBlocking { + rpc.events.emit(ChatEventDto.SessionUpdated("ses_1", session("ses_1", "/test", "Live"))) + } + settle() + + assertEquals(mapOf("ses_1" to "Live"), manager.titles()) + assertEquals("Live", history.titleText(0)) + } + fun `test dispose removes active component`() { val manager = manager() @@ -215,6 +342,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { } fun `test history back restores latest open session`() { + disableInactiveDispose() val manager = manager() manager.openSession(session("ses_1")) @@ -287,6 +415,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { } fun `test opening same cloud session while in-flight reuses existing ui`() { + disableInactiveDispose() rpc.historyGate = kotlinx.coroutines.CompletableDeferred() rpc.importedCloudSession = session("ses_imported") val manager = manager() @@ -306,6 +435,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { } fun `test imported cloud session is reused when opened as local`() { + disableInactiveDispose() rpc.importedCloudSession = session("ses_imported") val manager = manager() @@ -331,6 +461,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { } fun `test opening same local session while in-flight reuses existing ui`() { + disableInactiveDispose() val gate = kotlinx.coroutines.CompletableDeferred() rpc.historyGate = gate val manager = manager() @@ -368,6 +499,99 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { assertEquals(listOf("/test" to "ses_1", "/test" to "ses_1"), created) } + fun `test activity reports permission for live session ui`() { + val manager = manager() + rpc.statuses.value = mapOf("ses_1" to SessionStatusDto("busy")) + manager.openSession(session("ses_1")) + val active = active(manager) + com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait { + active.controller().model.setState(SessionState.AwaitingPermission(permission("ses_1"))) + } + + assertEquals(mapOf("ses_1" to SessionActivityKind.PERMISSION), manager.activity()) + } + + fun `test activity includes service running without retained ui`() { + val manager = manager() + rpc.statuses.value = mapOf("ses_1" to SessionStatusDto("busy")) + settle() + + assertEquals(mapOf("ses_1" to SessionActivityKind.RUNNING), manager.activity()) + } + + fun `test titles reports live session ui title`() { + val manager = manager() + manager.openSession(session("ses_1", "/test", "Stored")) + val active = active(manager) + com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait { + active.controller().model.setSession(session("ses_1", "/test", "Live")) + } + + assertEquals(mapOf("ses_1" to "Live"), manager.titles()) + } + + fun `test activity reports plan and question separately`() { + disableInactiveDispose() + val manager = manager() + manager.openSession(session("ses_plan")) + val plan = active(manager) + com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait { + plan.controller().model.setState(SessionState.AwaitingQuestion(question(plan = true))) + } + manager.openSession(session("ses_question")) + val question = active(manager) + com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait { + question.controller().model.setState(SessionState.AwaitingQuestion(question(plan = false))) + } + + assertEquals( + mapOf( + "ses_plan" to SessionActivityKind.PLAN, + "ses_question" to SessionActivityKind.QUESTION, + ), + manager.activity(), + ) + } + + fun `test inactive dispose keeps permission session ui`() { + enableDisposeInactiveUi() + val manager = manager() + manager.openSession(session("ses_1")) + val first = active(manager) + com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait { + first.controller().model.setState(SessionState.AwaitingPermission(permission("ses_1"))) + } + manager.openSession(session("ses_2")) + + assertTrue(ui.contains(first)) + assertEquals(mapOf("ses_1" to SessionActivityKind.PERMISSION), manager.activity()) + } + + fun `test inactive dispose keeps busy session ui`() { + enableDisposeInactiveUi() + val manager = manager() + manager.openSession(session("ses_1")) + val first = active(manager) + com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait { + first.controller().model.setState(SessionState.Busy("running")) + } + manager.openSession(session("ses_2")) + + assertTrue(ui.contains(first)) + } + + fun `test activity ignores disposed idle session ui`() { + enableDisposeInactiveUi() + val manager = manager() + manager.openSession(session("ses_1")) + val first = active(manager) + settle() + manager.openSession(session("ses_2")) + + assertFalse(ui.contains(first)) + assertEquals(emptyMap(), manager.activity()) + } + private fun manager( history: ((com.intellij.openapi.Disposable, (SessionRef) -> Unit, (String) -> Unit) -> JComponent)? = null, ): SessionSidePanelManager { @@ -388,6 +612,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { } }, resolve = { workspaces.workspace(it) }, + status = { sessions.activity() }, history = history, ) managers.add(manager) @@ -396,6 +621,29 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { private fun active(manager: SessionSidePanelManager) = manager.component.getComponent(0) as JPanel + private fun enableDisposeInactiveUi() = setInactiveDispose(true) + + private fun disableInactiveDispose() = setInactiveDispose(false) + + private fun setInactiveDispose(enabled: Boolean) { + val key = "kilo.session.inactive.dispose" + Registry.mutateContributedKeys { + it + (key to RegistryKeyDescriptor( + key, + "Dispose inactive session UI when switching sessions instead of retaining it in memory.", + "false", + false, + false, + null, + null, + )) + } + Disposer.register(testRootDisposable) { + Registry.mutateContributedKeys { it - key } + } + Registry.get(key).setValue(enabled, testRootDisposable) + } + private fun JPanel.controller(): ai.kilocode.client.session.controller.SessionController { val field = SessionUi::class.java.getDeclaredField("controller") field.isAccessible = true @@ -440,4 +688,41 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { version = 1.0, ) + private fun permission(id: String) = Permission( + id = "perm_$id", + sessionId = id, + name = "bash", + patterns = emptyList(), + always = emptyList(), + meta = PermissionMeta(), + ) + + private fun question(plan: Boolean) = Question( + id = "qst", + items = listOf( + QuestionItem( + question = "Question?", + header = "Header", + options = emptyList(), + multiple = false, + custom = false, + questionKey = if (plan) "plan.followup.question" else null, + ), + ), + ) + + private fun rpcQuestion(id: String) = QuestionRequestDto( + id = id, + sessionID = "ses_1", + questions = listOf( + QuestionInfoDto( + question = "Pick one", + header = "Choice", + options = emptyList(), + multiple = false, + custom = true, + ), + ), + ) + } 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 c9d4eb34b06..52fb937892c 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, sessions, listOf(rpc)) + val panel = ai.kilocode.client.session.ui.EmptySessionPanel(testRootDisposable, controller, listOf(rpc)) panel.clickRecent(0) @@ -87,9 +87,9 @@ class SessionUiFactoryTest : BasePlatformTestCase() { val panel = ai.kilocode.client.session.ui.EmptySessionPanel( testRootDisposable, controller, - sessions, emptyList(), - ) { manager.showHistory() } + history = { manager.showHistory() }, + ) panel.clickShowHistory() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionUpdateQueueTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionUpdateQueueTest.kt index 1a10efeabc6..75c2e6887ab 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionUpdateQueueTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionUpdateQueueTest.kt @@ -6,8 +6,12 @@ import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.client.session.model.SessionState import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.DiffFileDto +import ai.kilocode.rpc.dto.QuestionInfoDto +import ai.kilocode.rpc.dto.QuestionOptionDto +import ai.kilocode.rpc.dto.QuestionRequestDto import ai.kilocode.rpc.dto.SessionStatusDto import ai.kilocode.rpc.dto.TodoDto +import ai.kilocode.rpc.dto.ToolRefDto class SessionUpdateQueueTest : SessionControllerTestBase() { @@ -38,6 +42,66 @@ class SessionUpdateQueueTest : SessionControllerTestBase() { assertTrue(m.model.state is SessionState.Busy) } + fun `test hidden controller applies question metadata without flushing transcript`() { + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test", flushMs = 250L) + val modelEvents = collectModelEvents(m) + flush() + modelEvents.clear() + + hide(m) + emit(ChatEventDto.QuestionAsked("ses_test", question("q1")), flush = false) + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")), flush = false) + settle() + + assertModelEvents(""" + StateChanged AwaitingQuestion + """, modelEvents) + assertTrue(m.model.state is SessionState.AwaitingQuestion) + assertNull(m.model.message("msg1")) + + show(m) + settle() + + assertTrue(m.model.state is SessionState.AwaitingQuestion) + assertNotNull(m.model.message("msg1")) + } + + fun `test hidden controller applies session title metadata without show`() { + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test", flushMs = 250L) + flush() + + hide(m) + emit(ChatEventDto.SessionUpdated("ses_test", session("ses_test", title = "Hidden title")), flush = false) + settle() + + assertEquals("Hidden title", m.model.session?.title) + assertNull(m.model.message("msg1")) + } + + fun `test hidden controller consumes matching question reply metadata`() { + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test", flushMs = 250L) + val modelEvents = collectModelEvents(m) + flush() + modelEvents.clear() + + hide(m) + emit(ChatEventDto.QuestionAsked("ses_test", question("q1")), flush = false) + emit(ChatEventDto.QuestionReplied("ses_test", "q1"), flush = false) + settle() + + assertModelEvents(""" + StateChanged AwaitingQuestion + StateChanged Busy + """, modelEvents) + assertTrue(m.model.state is SessionState.Busy) + } + fun `test hidden controller condenses while hidden but does not flush`() { appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) projectRpc.state.value = workspaceReady() @@ -363,6 +427,21 @@ class SessionUpdateQueueTest : SessionControllerTestBase() { assertTrue(m.model.state is SessionState.Busy) } + private fun question(id: String) = QuestionRequestDto( + id = id, + sessionID = "ses_test", + questions = listOf( + QuestionInfoDto( + question = "Pick one", + header = "Choice", + options = listOf(QuestionOptionDto("A", "Option A")), + multiple = false, + custom = true, + ), + ), + tool = ToolRefDto("msg1", "call1"), + ) + private fun corpus(): List = buildList { add(ChatEventDto.TurnOpen("ses_test")) add(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/history/HistoryActivitySnapshotTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/history/HistoryActivitySnapshotTest.kt new file mode 100644 index 00000000000..1c7c1930752 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/history/HistoryActivitySnapshotTest.kt @@ -0,0 +1,69 @@ +package ai.kilocode.client.session.history + +import ai.kilocode.client.session.SessionActivityKind +import junit.framework.TestCase + +class HistoryActivitySnapshotTest : TestCase() { + fun `test activity kind change is changed`() { + val prev = HistoryActivitySnapshot(activity = mapOf("ses_1" to SessionActivityKind.RUNNING)) + val next = HistoryActivitySnapshot(activity = mapOf("ses_1" to SessionActivityKind.QUESTION)) + + assertEquals(setOf("ses_1"), prev.changed(next)) + } + + fun `test activity removal is changed`() { + val prev = HistoryActivitySnapshot(activity = mapOf("ses_1" to SessionActivityKind.RUNNING)) + + assertEquals(setOf("ses_1"), prev.changed(HistoryActivitySnapshot())) + } + + fun `test title change is changed`() { + val prev = HistoryActivitySnapshot( + activity = mapOf("ses_1" to SessionActivityKind.RUNNING), + titles = mapOf("ses_1" to "Old"), + ) + val next = HistoryActivitySnapshot( + activity = mapOf("ses_1" to SessionActivityKind.RUNNING), + titles = mapOf("ses_1" to "New"), + ) + + assertEquals(setOf("ses_1"), prev.changed(next)) + } + + fun `test title removal is changed`() { + val prev = HistoryActivitySnapshot(titles = mapOf("ses_1" to "Live")) + + assertEquals(setOf("ses_1"), prev.changed(HistoryActivitySnapshot())) + } + + fun `test disposed overlay removal is changed once`() { + val prev = HistoryActivitySnapshot( + activity = mapOf("ses_1" to SessionActivityKind.PERMISSION), + titles = mapOf("ses_1" to "Live"), + ) + + assertEquals(setOf("ses_1"), prev.changed(HistoryActivitySnapshot())) + } + + fun `test unchanged maps are not changed`() { + val prev = HistoryActivitySnapshot( + activity = mapOf("ses_1" to SessionActivityKind.PERMISSION), + titles = mapOf("ses_1" to "Live"), + ) + + assertEquals(emptySet(), prev.changed(prev.copy())) + } + + fun `test changed ids are unioned`() { + val prev = HistoryActivitySnapshot( + activity = mapOf("ses_1" to SessionActivityKind.RUNNING), + titles = mapOf("ses_2" to "Old"), + ) + val next = HistoryActivitySnapshot( + activity = mapOf("ses_1" to SessionActivityKind.QUESTION), + titles = mapOf("ses_2" to "New"), + ) + + assertEquals(setOf("ses_1", "ses_2"), prev.changed(next)) + } +} 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 1c96a918da3..cc014f97ee7 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 @@ -5,6 +5,7 @@ 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.SessionActivityKind import ai.kilocode.client.session.SessionRef import ai.kilocode.client.testing.FakeSessionRpcApi import ai.kilocode.client.testing.FakeWorkspaceRpcApi @@ -116,7 +117,7 @@ class HistoryControllerTest : BasePlatformTestCase() { assertTrue(controller.local.items.isEmpty()) } - fun `test activity returns busy ids and changed ids`() { + fun `test activity returns typed items`() { rpc.statuses.value = mapOf( "ses_busy" to SessionStatusDto("busy"), "ses_idle" to SessionStatusDto("idle"), @@ -125,28 +126,47 @@ class HistoryControllerTest : BasePlatformTestCase() { ) flush() - val snap = sessions.activity(setOf("ses_old", "ses_busy")) + val activity = sessions.activity() - assertEquals(setOf("ses_busy"), snap.active) - assertEquals(setOf("ses_old"), snap.changed) + assertEquals(mapOf("ses_busy" to SessionActivityKind.RUNNING), activity) + } + + fun `test controller activity returns service activity`() { + rpc.statuses.value = mapOf("ses_1" to SessionStatusDto("busy")) + flush() + + val activity = controller().activity() + + assertEquals(mapOf("ses_1" to SessionActivityKind.RUNNING), activity) } 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") } + val renderer = LocalHistoryRenderer(controller.local, activity = { mapOf("ses_1" to SessionActivityKind.RUNNING) }) renderer.getListCellRendererComponent(javax.swing.JList(arrayOf(item)), item, 0, false, false) assertTrue(renderer.runningVisible()) } + fun `test local history renderer uses title overlay`() { + val item = LocalHistoryItem(session("ses_1", "Stored")) + val controller = controller() + controller.local.replace(listOf(item)) + val renderer = LocalHistoryRenderer(controller.local, titles = { mapOf("ses_1" to "Live") }) + + renderer.getListCellRendererComponent(javax.swing.JList(arrayOf(item)), item, 0, false, false) + + assertEquals("Live", renderer.titleText()) + } + 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() } + val renderer = CloudHistoryRenderer(controller.cloud) { emptyMap() } renderer.getListCellRendererComponent(javax.swing.JList(arrayOf(item)), item, 0, false, false) @@ -164,6 +184,64 @@ class HistoryControllerTest : BasePlatformTestCase() { panel.syncActivity() assertTrue(panel.runningBadgeVisible(0)) + assertEquals(KiloBundle.message("session.part.tool.running"), panel.badgeText(0)) + } + + fun `test history panel overlay shows specific badge`() { + rpc.listed += session("ses_1", "Local One") + val panel = HistoryPanel(parent, controller(), manager = object : SessionManager { + override fun newSession() {} + override fun showHistory() {} + override fun openSession(ref: SessionRef) {} + override fun activity() = mapOf("ses_1" to SessionActivityKind.PERMISSION) + }) + flush() + + panel.syncActivity() + + assertEquals(KiloBundle.message("history.badge.permission"), panel.badgeText(0)) + } + + fun `test history panel sync repaints activity kind change`() { + rpc.listed += session("ses_1", "Local One") + var kind: SessionActivityKind? = null + val panel = HistoryPanel(parent, controller(), manager = object : SessionManager { + override fun newSession() {} + override fun showHistory() {} + override fun openSession(ref: SessionRef) {} + override fun activity() = sessions.activity() + kind?.let { mapOf("ses_1" to it) }.orEmpty() + }) + rpc.statuses.value = mapOf("ses_1" to SessionStatusDto("busy")) + flush() + + panel.syncActivity() + assertEquals(KiloBundle.message("session.part.tool.running"), panel.badgeText(0)) + + kind = SessionActivityKind.QUESTION + panel.syncActivity() + + assertEquals(KiloBundle.message("history.badge.question"), panel.badgeText(0)) + } + + fun `test history panel sync uses live title overlay`() { + rpc.listed += session("ses_1", "Stored") + var title = "Live" + val panel = HistoryPanel(parent, controller(), manager = object : SessionManager { + override fun newSession() {} + override fun showHistory() {} + override fun openSession(ref: SessionRef) {} + override fun titles() = title.takeIf { it.isNotBlank() }?.let { mapOf("ses_1" to it) }.orEmpty() + }) + flush() + + panel.syncActivity() + + assertEquals("Live", panel.titleText(0)) + + title = "" + panel.syncActivity() + + assertEquals("Stored", panel.titleText(0)) } fun `test panel filters and switches source`() { 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 8ac21bb093e..521e678a67d 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 @@ -4,6 +4,8 @@ import ai.kilocode.client.app.KiloAppService 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.SessionActivityKind import ai.kilocode.client.session.SessionRef import ai.kilocode.client.session.history.HistoryTime import ai.kilocode.client.session.history.LocalHistoryItem @@ -201,6 +203,34 @@ class EmptySessionPanelTest : BasePlatformTestCase() { assertEquals("Untitled", label?.text) } + fun `test renderer uses title overlay`() { + val panel = panel( + recents = listOf(session("ses_1", title = "Stored")), + titles = { mapOf("ses_1" to "Live") }, + ) + + panel.syncActivity() + val cell = panel.rendererComponent(session("ses_1", title = "Stored")) as BorderLayoutPanel + + assertEquals("Live", titleText(cell)) + } + + fun `test sync activity removes title overlay`() { + var title = "Live" + val panel = panel( + recents = listOf(session("ses_1", title = "Stored")), + titles = { title.takeIf { it.isNotBlank() }?.let { mapOf("ses_1" to it) }.orEmpty() }, + ) + + panel.syncActivity() + assertEquals("Live", titleText(panel.rendererComponent(session("ses_1", title = "Stored")) as BorderLayoutPanel)) + + title = "" + panel.syncActivity() + + assertEquals("Stored", titleText(panel.rendererComponent(session("ses_1", title = "Stored")) as BorderLayoutPanel)) + } + 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")) @@ -209,7 +239,45 @@ class EmptySessionPanelTest : BasePlatformTestCase() { val cell = panel.rendererComponent(session("ses_1")) as BorderLayoutPanel - assertTrue(runningVisible(cell)) + assertEquals(KiloBundle.message("session.part.tool.running"), badgeText(cell)) + } + + fun `test renderer shows overlay badge for active recent session`() { + val panel = panel( + recents = listOf(session("ses_1")), + activity = { sessions.activity() + mapOf("ses_1" to SessionActivityKind.QUESTION) }, + ) + rpc.statuses.value = mapOf("ses_1" to SessionStatusDto("busy")) + flush() + panel.syncActivity() + + val cell = panel.rendererComponent(session("ses_1")) as BorderLayoutPanel + + assertEquals(KiloBundle.message("history.badge.question"), badgeText(cell)) + } + + fun `test sync activity updates recent badge kind change`() { + var kind: SessionActivityKind? = null + val panel = panel( + recents = listOf(session("ses_1")), + activity = { sessions.activity() + kind?.let { mapOf("ses_1" to it) }.orEmpty() }, + ) + rpc.statuses.value = mapOf("ses_1" to SessionStatusDto("busy")) + flush() + + panel.syncActivity() + assertEquals( + KiloBundle.message("session.part.tool.running"), + badgeText(panel.rendererComponent(session("ses_1")) as BorderLayoutPanel), + ) + + kind = SessionActivityKind.QUESTION + panel.syncActivity() + + assertEquals( + KiloBundle.message("history.badge.question"), + badgeText(panel.rendererComponent(session("ses_1")) as BorderLayoutPanel), + ) } fun `test renderer hides running badge for idle recent session`() { @@ -220,7 +288,7 @@ class EmptySessionPanelTest : BasePlatformTestCase() { val cell = panel.rendererComponent(session("ses_1")) as BorderLayoutPanel - assertFalse(runningVisible(cell)) + assertNull(badgeText(cell)) } fun `test timestamp normalization handles seconds and milliseconds`() { @@ -238,17 +306,28 @@ class EmptySessionPanelTest : BasePlatformTestCase() { assertEquals("4d ago", panel.text(session("ses_1", now - 345_600_000), now)) } - private fun panel(recents: List = emptyList(), history: () -> Unit = {}) = - EmptySessionPanel(testRootDisposable, controller, sessions, recents, history) + private fun panel( + recents: List = emptyList(), + history: () -> Unit = {}, + activity: () -> Map = { sessions.activity() }, + titles: () -> Map = { emptyMap() }, + ) = EmptySessionPanel(testRootDisposable, controller, recents, history, activity, titles) private fun flush() = runBlocking { delay(100) UIUtil.dispatchAllInvocationEvents() } - private fun runningVisible(cell: BorderLayoutPanel): Boolean = UIUtil.uiTraverser(cell) + private fun badgeText(cell: BorderLayoutPanel): String? = UIUtil.uiTraverser(cell) .filter(JBLabel::class.java) - .any { it.isVisible && it.icon is FilledBadgeIcon } + .mapNotNull { (it.icon as? FilledBadgeIcon)?.takeIf { _ -> it.isVisible }?.text } + .firstOrNull() + + private fun titleText(cell: BorderLayoutPanel): String? = UIUtil.uiTraverser(cell) + .filter(JBLabel::class.java) + .filter { it.icon == null } + .firstOrNull() + ?.text private fun session(id: String, updated: Long = 2_000L, title: String = "Title $id") = SessionDto( id = id,