fix(jetbrains): session loading state, history panel loading indicator, and model dedup

- Add SessionState.Loading and handle it in ProgressPanel and isBusy()
- Deduplicate setState calls in SessionModel (no-op when state unchanged)
- HistoryController now opens cloud items as SessionRef.Cloud instead of importing inline
- HistoryModel tracks loaded flag; HistoryPanel shows a loading card before first load
- SessionManager interface takes SessionRef instead of raw SessionDto
- build.gradle.kts: set splitModeServerPort for runIdeBackend task
- Test: NewSessionActionTest, SessionUiTestBase, SessionModelTest updated accordingly
This commit is contained in:
kirillk
2026-05-07 21:52:31 -04:00
parent 6b5ace20b7
commit 3d6586c883
13 changed files with 77 additions and 35 deletions
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Show loading immediately when opening JetBrains sessions without showing duplicate progress for new sessions.
+6
View File
@@ -66,6 +66,12 @@ intellijPlatform {
}
}
tasks {
runIdeBackend {
splitModeServerPort.set(12345)
}
}
tasks.named<JavaExec>("runIde") {
dependsOn(":backend:processResources")
jvmArgumentProviders += CommandLineArgumentProvider {
@@ -12,5 +12,9 @@ interface SessionManager {
fun showHistory()
fun openSession(session: SessionDto)
fun openSession(ref: SessionRef)
fun openSession(session: SessionDto) {
openSession(SessionRef.Local(session))
}
}
@@ -3,6 +3,7 @@ package ai.kilocode.client.session.history
import ai.kilocode.client.app.KiloSessionService
import ai.kilocode.client.app.Workspace
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.SessionRef
import ai.kilocode.rpc.dto.CloudSessionDto
import ai.kilocode.rpc.dto.SessionDto
import com.intellij.openapi.application.ApplicationManager
@@ -13,7 +14,7 @@ class HistoryController(
private val sessions: KiloSessionService,
private val workspace: Workspace,
private val cs: CoroutineScope,
open: (LocalHistoryItem) -> Unit = {},
open: (SessionRef) -> Unit = {},
private val deleted: (String) -> Unit = {},
) {
companion object {
@@ -24,7 +25,6 @@ class HistoryController(
val cloud = CloudHistoryModel()
private val deleting = mutableSetOf<String>()
private val importing = mutableSetOf<String>()
private val opener = open
private var git: String? = null
@@ -82,30 +82,11 @@ class HistoryController(
fun deleting(item: LocalHistoryItem): Boolean = item.id in deleting
fun open(item: LocalHistoryItem) {
edt { opener(item) }
edt { opener(SessionRef.Local(item.session)) }
}
fun open(item: CloudHistoryItem) {
edt {
if (item.id in importing) return@edt
importing.add(item.id)
cloud.refresh()
cs.launch {
try {
val session = sessions.importCloudSession(item.id, workspace.directory)
edt {
importing.remove(item.id)
cloud.refresh()
opener(LocalHistoryItem(session))
}
} catch (e: Exception) {
edt {
importing.remove(item.id)
cloud.fail(e.message ?: KiloBundle.message("history.error.cloud"))
}
}
}
}
edt { opener(SessionRef.Cloud(item.session)) }
}
private fun loadCloud(reset: Boolean) {
@@ -11,6 +11,8 @@ open class HistoryModel<T : HistoryItem> : AbstractListModel<T>() {
private set
var error: String? = null
private set
var loaded = false
private set
val items: List<T> get() = all
val visibleItems: List<T> get() = rows
@@ -28,6 +30,7 @@ open class HistoryModel<T : HistoryItem> : AbstractListModel<T>() {
fun replace(items: List<T>) {
all = HistoryTime.sorted(items)
loading = false
loaded = true
error = null
filter()
}
@@ -35,6 +38,7 @@ open class HistoryModel<T : HistoryItem> : AbstractListModel<T>() {
fun append(items: List<T>) {
all = HistoryTime.sorted(all + items)
loading = false
loaded = true
error = null
filter()
}
@@ -46,6 +50,7 @@ open class HistoryModel<T : HistoryItem> : AbstractListModel<T>() {
fun fail(message: String) {
loading = false
loaded = true
error = message
refresh()
}
@@ -90,7 +95,7 @@ class CloudHistoryModel : HistoryModel<CloudHistoryItem>() {
fun start(reset: Boolean) {
cursor = cursor.takeUnless { reset }
start()
if (reset) clear()
if (reset && !loaded) clear()
}
fun replace(items: List<CloudHistoryItem>, next: String?) {
@@ -1,6 +1,8 @@
package ai.kilocode.client.session.history
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.ui.LoadingPanel
import ai.kilocode.client.session.ui.SessionStyle
import ai.kilocode.client.ui.UiStyle
import com.intellij.ide.ui.LafManagerListener
import com.intellij.openapi.application.ApplicationManager
@@ -22,6 +24,7 @@ import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import com.intellij.util.ui.components.BorderLayoutPanel
import java.awt.BorderLayout
import java.awt.CardLayout
import java.awt.event.HierarchyEvent
import java.awt.event.KeyEvent
import java.awt.event.MouseAdapter
@@ -48,6 +51,9 @@ class HistoryPanel(
private val more = JButton(KiloBundle.message("history.cloud.load.more"))
private val localPanel = panel(localSearch, localList)
private val cloudPanel = panel(cloudSearch, cloudList, more)
private val cards = CardLayout()
private val body = BorderLayoutPanel().apply { layout = cards }
private val load = LoadingPanel()
private val localInfo = TabInfo(localPanel).setText(KiloBundle.message("history.tab.local"))
private val cloudInfo = TabInfo(cloudPanel).setText(KiloBundle.message("history.tab.cloud"))
private var stale = false
@@ -79,7 +85,9 @@ class HistoryPanel(
}
if (!isShowing) stale = true
}
add(tabs.component, BorderLayout.CENTER)
body.add(load, CARD_LOAD)
body.add(tabs.component, CARD_TABS)
add(body, BorderLayout.CENTER)
sync()
refresh()
}
@@ -107,6 +115,7 @@ class HistoryPanel(
SwingUtilities.updateComponentTreeUI(this)
SwingUtilities.updateComponentTreeUI(localPanel)
SwingUtilities.updateComponentTreeUI(cloudPanel)
load.applyStyle(SessionStyle.current())
updateRenderer(localList)
updateRenderer(cloudList)
sync()
@@ -219,10 +228,16 @@ class HistoryPanel(
syncList(cloudList, controller.cloud)
more.isEnabled = controller.cloud.cursor != null && !controller.cloud.loading
more.isVisible = controller.cloud.cursor != null || controller.cloud.loading
cards.show(body, if (loading()) CARD_LOAD else CARD_TABS)
revalidate()
repaint()
}
private fun loading(): Boolean {
if (controller.local.loaded || controller.cloud.loaded) return false
return controller.local.loading || controller.cloud.loading
}
private fun <T : HistoryItem> syncList(list: JBList<T>, model: HistoryModel<T>) {
list.setPaintBusy(model.loading)
list.emptyText.text = when {
@@ -336,4 +351,11 @@ class HistoryPanel(
override fun dispose() {
// no-op
}
internal fun showingLoading() = !controller.local.loaded && !controller.cloud.loaded && (controller.local.loading || controller.cloud.loading)
private companion object {
const val CARD_LOAD = "load"
const val CARD_TABS = "tabs"
}
}
@@ -175,6 +175,7 @@ class SessionModel {
}
fun setState(state: SessionState) {
if (this.state == state) return
this.state = state
fire(SessionModelEvent.StateChanged(state))
updateHeader()
@@ -4,6 +4,8 @@ package ai.kilocode.client.session.model
sealed class SessionState {
data object Idle : SessionState()
data object Loading : SessionState()
data class Busy(val text: String) : SessionState()
data class AwaitingQuestion(val question: Question) : SessionState()
@@ -17,7 +19,7 @@ sealed class SessionState {
data class Error(val message: String, val kind: String? = null) : SessionState()
fun isBusy(): Boolean = when (this) {
is Idle, is Error -> false
is Idle, is Loading, is Error -> false
else -> true
}
}
@@ -1,6 +1,7 @@
package ai.kilocode.client.session.ui
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.SessionRef
import ai.kilocode.client.session.update.SessionController
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.md.MdView
@@ -67,7 +68,7 @@ class EmptySessionPanel(
val index = row(e)
if (index < 0) return
selectedIndex = index
controller.openSession(model.getElementAt(index))
controller.openSession(SessionRef.Local(model.getElementAt(index)))
}
override fun mouseExited(e: MouseEvent) {
@@ -153,7 +154,7 @@ class EmptySessionPanel(
internal fun clickRecent(index: Int) {
list.selectedIndex = index
controller.openSession(model.getElementAt(index))
controller.openSession(SessionRef.Local(model.getElementAt(index)))
}
internal fun recentVisible() = true
@@ -15,7 +15,7 @@ import java.awt.FlowLayout
*
* Reacts to [SessionModelEvent.StateChanged]:
* - [SessionState.Busy] → shows an animated spinner and [SessionState.Busy.text]
* - Any other state hidden
* - Any other state -> hidden
*
* Owned by [SessionMessageListPanel], which always re-anchors it as the last child so it
* appears below all turn views inside the scroll pane.
@@ -52,6 +52,7 @@ class ProgressPanel(
label.text = state.text
isVisible = true
}
is SessionState.Loading -> isVisible = false
else -> isVisible = false
}
revalidate()
@@ -1,7 +1,7 @@
package ai.kilocode.client.actions
import ai.kilocode.client.session.SessionManager
import ai.kilocode.rpc.dto.SessionDto
import ai.kilocode.client.session.SessionRef
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.actionSystem.DataContext
import com.intellij.openapi.actionSystem.Presentation
@@ -49,7 +49,10 @@ class NewSessionActionTest : BasePlatformTestCase() {
created++
}
override fun openSession(session: SessionDto) {
override fun showHistory() {
}
override fun openSession(ref: SessionRef) {
}
}
}
@@ -10,6 +10,7 @@ import ai.kilocode.client.session.update.SessionController
import ai.kilocode.client.testing.FakeAppRpcApi
import ai.kilocode.client.testing.FakeSessionRpcApi
import ai.kilocode.client.testing.FakeWorkspaceRpcApi
import ai.kilocode.client.session.SessionRef
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.KiloAppStateDto
import ai.kilocode.rpc.dto.KiloAppStatusDto
@@ -76,10 +77,9 @@ abstract class SessionUiTestBase : BasePlatformTestCase() {
protected fun newUi(
id: String? = null,
displayMs: Long = 0,
loading: Boolean = id == null,
open: (SessionDto) -> Unit = {},
open: (SessionRef) -> Unit = {},
): SessionUi {
return SessionUi(project, workspace, sessions, app, scope, id = id, displayMs = displayMs, loading = loading, open = open).apply {
return SessionUi(project, workspace, sessions, app, scope, id = id, displayMs = displayMs, open = open).apply {
setSize(800, 600)
}
}
@@ -328,6 +328,17 @@ class SessionModelTest : UsefulTestCase() {
assertEquals(state, (events.single() as SessionModelEvent.StateChanged).state)
}
fun `test setState duplicate is no-op`() {
val state = SessionState.Busy("thinking")
model.setState(state)
events.clear()
model.setState(state)
assertTrue(events.isEmpty())
assertEquals(state, model.state)
}
fun `test setState to Error stores error data`() {
model.setState(SessionState.Error("something broke", "timeout"))