feat(jetbrains): keep history activity metadata fresh

This commit is contained in:
kirillk
2026-05-27 16:09:20 -04:00
parent aeeb8eb5d4
commit b16944ce24
24 changed files with 1003 additions and 143 deletions
+5
View File
@@ -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.
+5
View File
@@ -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.
@@ -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<String>,
val changed: Set<String>,
)
/**
* Project-level frontend service for session management.
*
@@ -90,13 +86,10 @@ class KiloSessionService internal constructor(
}
}
internal fun activity(previous: Set<String>): SessionActivitySnapshot {
val active = statuses.value
internal fun activity(): Map<String, SessionActivityKind> =
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) }
@@ -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()
}
}
@@ -14,6 +14,12 @@ interface SessionManager {
fun openSession(ref: SessionRef)
fun activity(): Map<String, SessionActivityKind> = emptyMap()
fun titles(): Map<String, String> = emptyMap()
fun activityChanged() {}
fun openSession(session: SessionDto) {
openSession(SessionRef.Local(session))
}
@@ -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<SessionUiFactory>().create(project, workspace, manager, ref)
},
private val resolve: (String) -> Workspace = { dir -> service<KiloWorkspaceService>().workspace(dir) },
private val status: () -> Map<String, SessionActivityKind> = { project.service<KiloSessionService>().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<String, SessionActivityKind> {
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<String, String> = 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()
@@ -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"
@@ -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(
@@ -23,6 +23,7 @@ internal class SessionUpdateQueue(
private val fire: (List<ChatEventDto>) -> 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
@@ -0,0 +1,13 @@
package ai.kilocode.client.session.history
import ai.kilocode.client.session.SessionActivityKind
internal data class HistoryActivitySnapshot(
val activity: Map<String, SessionActivityKind> = emptyMap(),
val titles: Map<String, String> = emptyMap(),
) {
fun changed(next: HistoryActivitySnapshot): Set<String> =
(activity.keys + next.activity.keys + titles.keys + next.titles.keys).filterTo(mutableSetOf()) {
activity[it] != next.activity[it] || titles[it] != next.titles[it]
}
}
@@ -60,7 +60,7 @@ class HistoryController(
reloadCloud()
}
internal fun activity(previous: Set<String>) = sessions.activity(previous)
internal fun activity() = sessions.activity()
fun reloadLocal() {
edt { local.start() }
@@ -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<T : HistoryItem>(
private val model: HistoryModel<T>,
private val deletable: Boolean,
private val active: () -> Set<String>,
private val activity: () -> Map<String, SessionActivityKind>,
private val titles: () -> Map<String, String> = { emptyMap() },
) : JPanel(BorderLayout()), ListCellRenderer<T> {
companion object {
private val icon: Icon = AllIcons.Actions.GC
@@ -80,6 +81,7 @@ internal open class HistoryRenderer<T : HistoryItem>(
if (deletable) add(del, BorderLayout.EAST)
}
private val wrap = PickerRow()
private var text = ""
init {
isOpaque = true
@@ -110,13 +112,14 @@ internal open class HistoryRenderer<T : HistoryItem>(
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<T : HistoryItem>(
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<LocalHistoryItem>,
active: () -> Set<String> = { emptySet() },
) : HistoryRenderer<LocalHistoryItem>(model, deletable = true, active)
activity: () -> Map<String, SessionActivityKind> = { emptyMap() },
titles: () -> Map<String, String> = { emptyMap() },
) : HistoryRenderer<LocalHistoryItem>(model, deletable = true, activity, titles)
internal class CloudHistoryRenderer(
model: HistoryModel<CloudHistoryItem>,
active: () -> Set<String> = { emptySet() },
) : HistoryRenderer<CloudHistoryItem>(model, deletable = false, active)
activity: () -> Map<String, SessionActivityKind> = { emptyMap() },
) : HistoryRenderer<CloudHistoryItem>(model, deletable = false, activity)
@@ -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<String>()
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 <T : HistoryItem> repaintRows(list: JBList<T>, model: HistoryModel<T>, ids: Set<String>) {
@@ -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<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()
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<HistoryItem>
@Suppress("UNCHECKED_CAST")
val typed = list as JList<HistoryItem>
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
}
}
@@ -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<SessionDto>,
private val history: () -> Unit = {},
private val activity: () -> Map<String, SessionActivityKind> = { emptyMap() },
private val titles: () -> Map<String, String> = { emptyMap() },
) : BorderLayoutPanel(), Disposable, SessionEditorStyleTarget {
val view: Align = align(HAlign.CENTER, VAlign.CENTER)
private val model = DefaultListModel<LocalHistoryItem>()
private var hover = -1
private var style = SessionEditorStyle.current()
private var active = emptySet<String>()
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<String>) {
@@ -263,13 +266,7 @@ 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 {
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<String, String>.changed(next: Map<String, String>) = (keys + next.keys).filterTo(mutableSetOf()) {
this[it] != next[it]
}
@@ -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 {
@@ -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())
@@ -39,6 +39,11 @@
defaultValue="150"
restartRequired="false"
overrides="false"/>
<registryKey key="kilo.session.inactive.dispose"
description="Dispose inactive session UI when switching sessions instead of retaining it in memory."
defaultValue="false"
restartRequired="false"
overrides="false"/>
</extensions>
<actions>
@@ -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
@@ -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<Unit>()
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<String, SessionActivityKind>(), 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,
),
),
)
}
@@ -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()
@@ -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<ChatEventDto> = buildList {
add(ChatEventDto.TurnOpen("ses_test"))
add(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")))
@@ -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<String>(), 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))
}
}
@@ -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`() {
@@ -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<SessionDto> = emptyList(), history: () -> Unit = {}) =
EmptySessionPanel(testRootDisposable, controller, sessions, recents, history)
private fun panel(
recents: List<SessionDto> = emptyList(),
history: () -> Unit = {},
activity: () -> Map<String, SessionActivityKind> = { sessions.activity() },
titles: () -> Map<String, String> = { 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,