mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
feat(jetbrains): keep history activity metadata fresh
This commit is contained in:
@@ -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.
|
||||
@@ -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
-11
@@ -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) }
|
||||
|
||||
+32
@@ -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()
|
||||
}
|
||||
}
|
||||
+6
@@ -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))
|
||||
}
|
||||
|
||||
+49
-8
@@ -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()
|
||||
|
||||
+47
-2
@@ -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"
|
||||
|
||||
+132
-64
@@ -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(
|
||||
|
||||
+5
@@ -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
|
||||
|
||||
+13
@@ -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]
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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() }
|
||||
|
||||
+26
-15
@@ -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)
|
||||
|
||||
+31
-10
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+22
-16
@@ -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]
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+285
@@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
+3
-3
@@ -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()
|
||||
|
||||
|
||||
+79
@@ -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")))
|
||||
|
||||
+69
@@ -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))
|
||||
}
|
||||
}
|
||||
+84
-6
@@ -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`() {
|
||||
|
||||
+85
-6
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user