From 3d6586c883f17e0ea72cc4f99fb407e6d4e60c4e Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 7 May 2026 21:52:31 -0400 Subject: [PATCH] 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 --- .changeset/jetbrains-session-loading-state.md | 5 ++++ packages/kilo-jetbrains/build.gradle.kts | 6 +++++ .../kilocode/client/session/SessionManager.kt | 6 ++++- .../session/history/HistoryController.kt | 27 +++---------------- .../client/session/history/HistoryModel.kt | 7 ++++- .../client/session/history/HistoryPanel.kt | 24 ++++++++++++++++- .../client/session/model/SessionModel.kt | 1 + .../client/session/model/SessionState.kt | 4 ++- .../client/session/ui/EmptySessionPanel.kt | 5 ++-- .../client/session/ui/ProgressPanel.kt | 3 ++- .../client/actions/NewSessionActionTest.kt | 7 +++-- .../client/session/SessionUiTestBase.kt | 6 ++--- .../client/session/model/SessionModelTest.kt | 11 ++++++++ 13 files changed, 77 insertions(+), 35 deletions(-) create mode 100644 .changeset/jetbrains-session-loading-state.md diff --git a/.changeset/jetbrains-session-loading-state.md b/.changeset/jetbrains-session-loading-state.md new file mode 100644 index 00000000000..bc930ebcbca --- /dev/null +++ b/.changeset/jetbrains-session-loading-state.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Show loading immediately when opening JetBrains sessions without showing duplicate progress for new sessions. diff --git a/packages/kilo-jetbrains/build.gradle.kts b/packages/kilo-jetbrains/build.gradle.kts index 3ecc8be04b6..00b4bd5ea2b 100644 --- a/packages/kilo-jetbrains/build.gradle.kts +++ b/packages/kilo-jetbrains/build.gradle.kts @@ -66,6 +66,12 @@ intellijPlatform { } } +tasks { + runIdeBackend { + splitModeServerPort.set(12345) + } +} + tasks.named("runIde") { dependsOn(":backend:processResources") jvmArgumentProviders += CommandLineArgumentProvider { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionManager.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionManager.kt index 162fd3c6b2a..62d8ec94074 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionManager.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionManager.kt @@ -12,5 +12,9 @@ interface SessionManager { fun showHistory() - fun openSession(session: SessionDto) + fun openSession(ref: SessionRef) + + fun openSession(session: SessionDto) { + openSession(SessionRef.Local(session)) + } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryController.kt index 2b0406172f0..08d1865c8b7 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryController.kt @@ -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() - private val importing = mutableSetOf() 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) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryModel.kt index 44543d2d37b..f6aa89d3be3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryModel.kt @@ -11,6 +11,8 @@ open class HistoryModel : AbstractListModel() { private set var error: String? = null private set + var loaded = false + private set val items: List get() = all val visibleItems: List get() = rows @@ -28,6 +30,7 @@ open class HistoryModel : AbstractListModel() { fun replace(items: List) { all = HistoryTime.sorted(items) loading = false + loaded = true error = null filter() } @@ -35,6 +38,7 @@ open class HistoryModel : AbstractListModel() { fun append(items: List) { all = HistoryTime.sorted(all + items) loading = false + loaded = true error = null filter() } @@ -46,6 +50,7 @@ open class HistoryModel : AbstractListModel() { fun fail(message: String) { loading = false + loaded = true error = message refresh() } @@ -90,7 +95,7 @@ class CloudHistoryModel : HistoryModel() { fun start(reset: Boolean) { cursor = cursor.takeUnless { reset } start() - if (reset) clear() + if (reset && !loaded) clear() } fun replace(items: List, next: String?) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryPanel.kt index ba03abed22f..6cfe54fffc5 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryPanel.kt @@ -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 syncList(list: JBList, model: HistoryModel) { 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" + } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt index 798d2bf3c6f..230cda662fe 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt @@ -175,6 +175,7 @@ class SessionModel { } fun setState(state: SessionState) { + if (this.state == state) return this.state = state fire(SessionModelEvent.StateChanged(state)) updateHeader() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionState.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionState.kt index 7d0f5f1b67b..156411aaf88 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionState.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionState.kt @@ -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 } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt index 1caf26b538d..6f693c36e44 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt @@ -1,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 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt index 2712a4e2952..d7c3357d1cb 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt @@ -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() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/NewSessionActionTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/NewSessionActionTest.kt index d9da97616d8..3de571b0d8f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/NewSessionActionTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/NewSessionActionTest.kt @@ -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) { } } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiTestBase.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiTestBase.kt index 96ba3937824..96b8b3b5d6d 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiTestBase.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiTestBase.kt @@ -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) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt index f468ff973af..38373fe9a80 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt @@ -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"))