From f95d67bab4276f4628b94ed4a2a022d4d36722f8 Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 23 Apr 2026 16:35:33 -0400 Subject: [PATCH 01/16] fix(jetbrains): simplify session loading panels --- .changeset/soft-garlic-care.md | 5 + .../client/actions/KiloSettingsAction.kt | 2 +- .../client/actions/ReinstallKiloAction.kt | 3 +- .../client/actions/RestartKiloAction.kt | 3 +- .../client/actions/StatusInfoAction.kt | 32 -- .../ai/kilocode/client/session/SessionUi.kt | 10 +- .../client/session/ui/ConnectionPanel.kt | 100 ++++++ .../client/session/ui/EmptySessionPanel.kt | 41 +++ .../kilocode/client/session/ui/StatusPanel.kt | 334 ------------------ .../resources/kilo.jetbrains.frontend.xml | 5 - .../resources/messages/KiloBundle.properties | 33 +- 11 files changed, 160 insertions(+), 408 deletions(-) create mode 100644 .changeset/soft-garlic-care.md delete mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/StatusInfoAction.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt delete mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/StatusPanel.kt diff --git a/.changeset/soft-garlic-care.md b/.changeset/soft-garlic-care.md new file mode 100644 index 00000000000..ee6f55a6d61 --- /dev/null +++ b/.changeset/soft-garlic-care.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Refine JetBrains session loading UI and settings actions. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/KiloSettingsAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/KiloSettingsAction.kt index 974bd38458f..b7f6ba5d12f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/KiloSettingsAction.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/KiloSettingsAction.kt @@ -29,7 +29,7 @@ class KiloSettingsAction : AnAction() { group, e.dataContext, JBPopupFactory.ActionSelectionAid.SPEEDSEARCH, - true, // showDisabledActions — StatusInfoAction is always disabled + true, ) .showUnderneathOf(component) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/ReinstallKiloAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/ReinstallKiloAction.kt index 242498d9b59..7eab2842c67 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/ReinstallKiloAction.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/ReinstallKiloAction.kt @@ -5,8 +5,9 @@ import ai.kilocode.rpc.dto.KiloAppStatusDto import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.components.service +import com.intellij.openapi.project.DumbAware -class ReinstallKiloAction : AnAction() { +class ReinstallKiloAction : AnAction(), DumbAware { override fun actionPerformed(e: AnActionEvent) { service().reinstallAsync() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/RestartKiloAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/RestartKiloAction.kt index f69c8c9668d..335cf611f0f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/RestartKiloAction.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/RestartKiloAction.kt @@ -5,8 +5,9 @@ import ai.kilocode.rpc.dto.KiloAppStatusDto import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.components.service +import com.intellij.openapi.project.DumbAware -class RestartKiloAction : AnAction() { +class RestartKiloAction : AnAction(), DumbAware { override fun actionPerformed(e: AnActionEvent) { service().restartAsync() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/StatusInfoAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/StatusInfoAction.kt deleted file mode 100644 index 7f808f158ae..00000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/StatusInfoAction.kt +++ /dev/null @@ -1,32 +0,0 @@ -package ai.kilocode.client.actions - -import ai.kilocode.client.app.KiloAppService -import ai.kilocode.client.plugin.KiloBundle -import ai.kilocode.rpc.dto.KiloAppStatusDto -import com.intellij.openapi.actionSystem.AnAction -import com.intellij.openapi.actionSystem.AnActionEvent -import com.intellij.openapi.components.service - -/** - * Non-interactive info row at the bottom of the settings popup showing - * connection status and CLI version (from the last health check). - */ -class StatusInfoAction : AnAction() { - override fun actionPerformed(e: AnActionEvent) { - // intentionally non-actionable - } - - override fun update(e: AnActionEvent) { - val svc = service() - val status = when (svc.state.value.status) { - KiloAppStatusDto.READY -> KiloBundle.message("toolwindow.status.connected.short") - KiloAppStatusDto.CONNECTING -> KiloBundle.message("toolwindow.status.connecting.short") - KiloAppStatusDto.LOADING -> KiloBundle.message("toolwindow.status.loading.short") - KiloAppStatusDto.DISCONNECTED -> KiloBundle.message("toolwindow.status.disconnected.short") - KiloAppStatusDto.ERROR -> KiloBundle.message("toolwindow.status.error.short") - } - val ver = svc.version?.let { " · $it" } ?: "" - e.presentation.text = "$status$ver" - e.presentation.isEnabled = false - } -} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index b9b4856c4d2..9212f9816ce 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -5,12 +5,13 @@ import ai.kilocode.client.app.KiloSessionService import ai.kilocode.client.app.Workspace import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.client.session.model.SessionState +import ai.kilocode.client.session.ui.ConnectionPanel +import ai.kilocode.client.session.ui.EmptySessionPanel import ai.kilocode.client.session.ui.LabelPicker import ai.kilocode.client.session.ui.PermissionPanel import ai.kilocode.client.session.ui.PromptPanel import ai.kilocode.client.session.ui.QuestionPanel import ai.kilocode.client.session.ui.SessionPanel -import ai.kilocode.client.session.ui.StatusPanel import com.intellij.openapi.Disposable import com.intellij.openapi.project.Project import com.intellij.openapi.util.registry.Registry @@ -28,7 +29,8 @@ import javax.swing.JPanel * Top-level session UI — a thin composition root. * * Responsibilities: - * - Creates and wires [SessionController], [SessionPanel], [StatusPanel], + * - Creates and wires [SessionController], [SessionPanel], [EmptySessionPanel], + * [ConnectionPanel], * [PromptPanel], [QuestionPanel], [PermissionPanel]. * - Switches between the status (loading) card and the transcript card via * [SessionControllerEvent.ViewChanged]. @@ -71,7 +73,7 @@ class SessionUi( // ------ status (loading) panel ------ - private val status = StatusPanel(this, controller) + private val status = EmptySessionPanel(this) // ------ transcript ------ @@ -87,6 +89,7 @@ class SessionUi( private val question = QuestionPanel(controller) private val permission = PermissionPanel(controller) + private val connection = ConnectionPanel(this, controller) // ------ prompt ------ @@ -105,6 +108,7 @@ class SessionUi( isOpaque = false add(question) add(permission) + add(connection) add(prompt) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt new file mode 100644 index 00000000000..34c925e9e4d --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt @@ -0,0 +1,100 @@ +package ai.kilocode.client.session.ui + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.SessionController +import ai.kilocode.client.session.SessionControllerEvent +import ai.kilocode.client.session.SessionControllerListener +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import com.intellij.openapi.Disposable +import com.intellij.openapi.util.Disposer +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import java.awt.BorderLayout +import javax.swing.JPanel + +class ConnectionPanel( + parent: Disposable, + private val controller: SessionController, +) : JPanel(BorderLayout()), SessionControllerListener, Disposable { + + private val label = JBLabel().apply { + border = JBUI.Borders.empty(4, 8) + foreground = UIUtil.getContextHelpForeground() + } + + init { + Disposer.register(parent, this) + isOpaque = false + add(label, BorderLayout.CENTER) + controller.addListener(this, this) + render() + } + + override fun onEvent(event: SessionControllerEvent) { + when (event) { + is SessionControllerEvent.AppChanged, + is SessionControllerEvent.WorkspaceChanged -> render() + + else -> Unit + } + } + + private fun render() { + val app = controller.model.app + val workspace = controller.model.workspace + + if (app.status == KiloAppStatusDto.ERROR) { + label.foreground = UIUtil.getErrorForeground() + label.text = app.error ?: KiloBundle.message("session.connection.error.unknown") + showPanel() + return + } + + if (workspace.status == KiloWorkspaceStatusDto.ERROR) { + label.foreground = UIUtil.getErrorForeground() + label.text = workspace.error ?: KiloBundle.message("session.connection.error.unknown") + showPanel() + return + } + + if (app.status == KiloAppStatusDto.READY && workspace.status == KiloWorkspaceStatusDto.READY) { + hidePanel() + return + } + + label.foreground = UIUtil.getContextHelpForeground() + label.text = KiloBundle.message("session.connection.connecting") + showPanel() + } + + private fun showPanel() { + if (!isVisible) { + isVisible = true + refresh() + return + } + refresh() + } + + private fun hidePanel() { + if (isVisible) { + isVisible = false + refresh() + return + } + refresh() + } + + private fun refresh() { + parent?.revalidate() + parent?.repaint() + revalidate() + repaint() + } + + override fun dispose() { + // no-op + } +} 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 new file mode 100644 index 00000000000..8aa2c1208ee --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt @@ -0,0 +1,41 @@ +package ai.kilocode.client.session.ui + +import com.intellij.openapi.Disposable +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.IconLoader +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBUI +import java.awt.GridBagConstraints +import java.awt.GridBagLayout +import javax.swing.JPanel + +/** + * Centered empty-session panel. + */ +class EmptySessionPanel( + parent: Disposable, +) : JPanel(GridBagLayout()), Disposable { + + init { + Disposer.register(parent, this) + } + + private val logo = JBLabel( + IconLoader.getIcon("/icons/kilo-content.svg", EmptySessionPanel::class.java), + ).apply { + alignmentX = CENTER_ALIGNMENT + } + + init { + isOpaque = false + + add(logo, GridBagConstraints().apply { + anchor = GridBagConstraints.CENTER + insets = JBUI.insets(12) + }) + } + + override fun dispose() { + // no-op + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/StatusPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/StatusPanel.kt deleted file mode 100644 index a27b5471129..00000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/StatusPanel.kt +++ /dev/null @@ -1,334 +0,0 @@ -package ai.kilocode.client.session.ui - -import ai.kilocode.client.plugin.KiloBundle -import ai.kilocode.client.session.SessionController -import ai.kilocode.client.session.SessionControllerEvent -import ai.kilocode.client.session.SessionControllerListener -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.ProfileStatusDto -import com.intellij.icons.AllIcons -import com.intellij.openapi.Disposable -import com.intellij.openapi.util.Disposer -import com.intellij.openapi.util.IconLoader -import com.intellij.ui.AnimatedIcon -import com.intellij.ui.components.JBLabel -import com.intellij.util.ui.JBUI -import com.intellij.util.ui.UIUtil -import java.awt.Font -import java.awt.GridBagConstraints -import java.awt.GridBagLayout -import javax.swing.Box -import javax.swing.BoxLayout -import javax.swing.Icon -import javax.swing.JPanel -import javax.swing.SwingConstants - -/** - * Welcome panel showing app + workspace initialization progress. - * - * Pure view — listens to [SessionController] events and reads - * [SessionModel][ai.kilocode.client.session.model.SessionModel] for data. - * No coroutines, no service references. - * - * Uses icon+label rows for each resource being loaded. Icons act as - * status indicators: animated spinner for loading, green check for - * success, red circle for error, grey circle for idle. - */ -class StatusPanel( - parent: Disposable, - private val controller: SessionController, -) : JPanel(GridBagLayout()), SessionControllerListener, Disposable { - - init { - Disposer.register(parent, this) - } - - // ------ status icons ------ - - private val iconLoading: Icon = AnimatedIcon.Default() - private val iconOk: Icon = AllIcons.RunConfigurations.TestPassed - private val iconError: Icon = AllIcons.RunConfigurations.TestFailed - private val iconWarn: Icon = AllIcons.General.Warning - private val iconIdle: Icon = AllIcons.RunConfigurations.TestNotRan - - // ------ header ------ - - private val logo = JBLabel( - IconLoader.getIcon("/icons/kilo-content.svg", StatusPanel::class.java), - ).apply { - alignmentX = CENTER_ALIGNMENT - } - - private val status = JBLabel().apply { - alignmentX = CENTER_ALIGNMENT - horizontalAlignment = SwingConstants.CENTER - font = JBUI.Fonts.label(13f) - foreground = UIUtil.getLabelForeground() - } - - // ------ app rows ------ - - private val configRow = row(KiloBundle.message("toolwindow.row.config")) - private val notifRow = row(KiloBundle.message("toolwindow.row.notifications")) - private val profileRow = row(KiloBundle.message("toolwindow.row.profile")) - - // ------ workspace rows ------ - - private val providersRow = row(KiloBundle.message("toolwindow.row.providers")) - private val agentsRow = row(KiloBundle.message("toolwindow.row.agents")) - private val commandsRow = row(KiloBundle.message("toolwindow.row.commands")) - private val skillsRow = row(KiloBundle.message("toolwindow.row.skills")) - - // ------ section headers ------ - - private val appHeader = header(KiloBundle.message("toolwindow.section.app")) - private val wsHeader = header(KiloBundle.message("toolwindow.section.workspace")) - - private val appSection = section(appHeader, configRow, notifRow, profileRow) - private val wsSection = section(wsHeader, providersRow, agentsRow, commandsRow, skillsRow) - - init { - isOpaque = false - - val body = JPanel().apply { - layout = BoxLayout(this, BoxLayout.Y_AXIS) - isOpaque = false - border = JBUI.Borders.empty(12, 16) - - add(logo) - add(Box.createVerticalStrut(JBUI.scale(12))) - add(status) - add(Box.createVerticalStrut(JBUI.scale(12))) - add(appSection) - add(Box.createVerticalStrut(JBUI.scale(12))) - add(wsSection) - } - - add(body, GridBagConstraints()) - - resetAll() - controller.addListener(this, this) - } - - override fun onEvent(event: SessionControllerEvent) { - when (event) { - is SessionControllerEvent.AppChanged -> { - renderApp(controller.model.app) - revalidate() - repaint() - } - - is SessionControllerEvent.WorkspaceChanged -> { - renderWorkspace(controller.model.workspace) - revalidate() - repaint() - } - - else -> {} - } - } - - // ------ rendering ------ - - private fun renderApp(state: KiloAppStateDto) { - status.text = title(state) - - when (state.status) { - KiloAppStatusDto.DISCONNECTED -> { - resetAll() - } - KiloAppStatusDto.CONNECTING -> { - configRow.loading() - notifRow.loading() - profileRow.loading() - } - KiloAppStatusDto.LOADING -> { - val p = state.progress - if (p != null) { - if (p.config) configRow.ok(KiloBundle.message("toolwindow.row.config")) else configRow.loading() - if (p.notifications) notifRow.ok(KiloBundle.message("toolwindow.row.notifications")) else notifRow.loading() - renderProfile(p.profile) - } - } - KiloAppStatusDto.READY -> { - val p = state.progress - if (p != null) { - configRow.ok(KiloBundle.message("toolwindow.row.config")) - notifRow.ok(KiloBundle.message("toolwindow.row.notifications")) - renderProfile(p.profile) - } else { - configRow.ok(KiloBundle.message("toolwindow.row.config")) - notifRow.ok(KiloBundle.message("toolwindow.row.notifications")) - profileRow.ok(KiloBundle.message("toolwindow.profile.loggedin")) - } - } - KiloAppStatusDto.ERROR -> { - val errors = state.errors.associate { it.resource to it } - configRow.apply { - val detail = errors["config"]?.detail ?: KiloBundle.message("toolwindow.error.failed") - if ("config" in errors) error(KiloBundle.message("toolwindow.error.config", detail)) - else ok(KiloBundle.message("toolwindow.row.config")) - } - notifRow.apply { - val detail = errors["notifications"]?.detail ?: KiloBundle.message("toolwindow.error.failed") - if ("notifications" in errors) error(KiloBundle.message("toolwindow.error.notifications", detail)) - else ok(KiloBundle.message("toolwindow.row.notifications")) - } - profileRow.apply { - val detail = errors["profile"]?.detail ?: KiloBundle.message("toolwindow.error.failed") - if ("profile" in errors) error(KiloBundle.message("toolwindow.error.profile", detail)) - else ok(KiloBundle.message("toolwindow.profile.loggedin")) - } - } - } - } - - private fun renderWorkspace(state: KiloWorkspaceStateDto) { - val appReady = controller.model.app.status == KiloAppStatusDto.READY - val visible = appReady || state.status != KiloWorkspaceStatusDto.PENDING - wsSection.isVisible = visible - if (!visible) return - - when (state.status) { - KiloWorkspaceStatusDto.PENDING -> { - providersRow.idle(KiloBundle.message("toolwindow.row.providers")) - agentsRow.idle(KiloBundle.message("toolwindow.row.agents")) - commandsRow.idle(KiloBundle.message("toolwindow.row.commands")) - skillsRow.idle(KiloBundle.message("toolwindow.row.skills")) - } - KiloWorkspaceStatusDto.LOADING -> { - val p = state.progress - if (p != null) { - if (p.providers) providersRow.ok(KiloBundle.message("toolwindow.row.providers")) else providersRow.loading() - if (p.agents) agentsRow.ok(KiloBundle.message("toolwindow.row.agents")) else agentsRow.loading() - if (p.commands) commandsRow.ok(KiloBundle.message("toolwindow.row.commands")) else commandsRow.loading() - if (p.skills) skillsRow.ok(KiloBundle.message("toolwindow.row.skills")) else skillsRow.loading() - } else { - providersRow.loading() - agentsRow.loading() - commandsRow.loading() - skillsRow.loading() - } - } - KiloWorkspaceStatusDto.READY -> { - val prov = state.providers?.providers?.size ?: 0 - val ag = state.agents?.all?.size ?: 0 - val cmd = state.commands.size - val sk = state.skills.size - providersRow.ok(KiloBundle.message("toolwindow.row.providers.count", prov)) - agentsRow.ok(KiloBundle.message("toolwindow.row.agents.count", ag)) - commandsRow.ok(KiloBundle.message("toolwindow.row.commands.count", cmd)) - skillsRow.ok(KiloBundle.message("toolwindow.row.skills.count", sk)) - } - KiloWorkspaceStatusDto.ERROR -> { - val msg = state.error ?: KiloBundle.message("toolwindow.error.unknown") - providersRow.error(msg) - agentsRow.idle(KiloBundle.message("toolwindow.row.agents")) - commandsRow.idle(KiloBundle.message("toolwindow.row.commands")) - skillsRow.idle(KiloBundle.message("toolwindow.row.skills")) - } - } - } - - // ------ helpers ------ - - private fun title(state: KiloAppStateDto): String = - when (state.status) { - KiloAppStatusDto.DISCONNECTED -> KiloBundle.message("toolwindow.status.disconnected") - KiloAppStatusDto.CONNECTING -> KiloBundle.message("toolwindow.status.connecting") - KiloAppStatusDto.LOADING -> KiloBundle.message("toolwindow.status.loading") - KiloAppStatusDto.READY -> { - val ver = controller.model.version - if (ver != null) KiloBundle.message("toolwindow.status.connected.version", ver) - else KiloBundle.message("toolwindow.status.connected") - } - KiloAppStatusDto.ERROR -> KiloBundle.message( - "toolwindow.status.error", - state.error ?: KiloBundle.message("toolwindow.error.unknown"), - ) - } - - private fun renderProfile(profile: ProfileStatusDto) { - when (profile) { - ProfileStatusDto.LOADED -> profileRow.ok(KiloBundle.message("toolwindow.profile.loggedin")) - ProfileStatusDto.NOT_LOGGED_IN -> profileRow.warn(KiloBundle.message("toolwindow.profile.notloggedin")) - ProfileStatusDto.PENDING -> profileRow.loading(KiloBundle.message("toolwindow.row.profile")) - } - } - - private fun resetAll() { - configRow.idle(KiloBundle.message("toolwindow.row.config")) - notifRow.idle(KiloBundle.message("toolwindow.row.notifications")) - profileRow.idle(KiloBundle.message("toolwindow.row.profile")) - providersRow.idle(KiloBundle.message("toolwindow.row.providers")) - agentsRow.idle(KiloBundle.message("toolwindow.row.agents")) - commandsRow.idle(KiloBundle.message("toolwindow.row.commands")) - skillsRow.idle(KiloBundle.message("toolwindow.row.skills")) - } - - // ------ row factory ------ - - private fun row(text: String): StatusRow = StatusRow(text, iconIdle) - - private fun header(text: String): JBLabel = JBLabel(text).apply { - alignmentX = LEFT_ALIGNMENT - font = JBUI.Fonts.label().deriveFont(JBUI.Fonts.label().style or Font.BOLD) - foreground = UIUtil.getLabelForeground() - border = JBUI.Borders.empty(0, 0, 4, 0) - } - - private fun section(hdr: JBLabel, vararg rows: StatusRow): JPanel = JPanel().apply { - layout = BoxLayout(this, BoxLayout.Y_AXIS) - isOpaque = false - alignmentX = CENTER_ALIGNMENT - add(hdr) - for (r in rows) add(r.label) - } - - inner class StatusRow(text: String, icon: Icon) { - val label = JBLabel(text, icon, SwingConstants.LEFT).apply { - font = JBUI.Fonts.label() - foreground = UIUtil.getContextHelpForeground() - iconTextGap = JBUI.scale(6) - border = JBUI.Borders.empty(2, 0) - alignmentX = LEFT_ALIGNMENT - } - - fun ok(msg: String, ic: Icon = iconOk) { - label.icon = ic - label.text = msg - label.foreground = UIUtil.getContextHelpForeground() - } - - fun loading(msg: String = label.text) { - label.icon = iconLoading - label.text = msg - label.foreground = UIUtil.getContextHelpForeground() - } - - fun warn(msg: String) { - label.icon = iconWarn - label.text = msg - label.foreground = UIUtil.getContextHelpForeground() - } - - fun error(msg: String) { - label.icon = iconError - label.text = msg - label.foreground = UIUtil.getErrorForeground() - } - - fun idle(msg: String) { - label.icon = iconIdle - label.text = msg - label.foreground = UIUtil.getContextHelpForeground() - } - } - - override fun dispose() { - // Listener auto-removed by Disposer (registered in init via addListener) - } -} diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml index 5922e16ebd1..d865deb63fe 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml +++ b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml @@ -31,14 +31,9 @@ - - - - Date: Fri, 24 Apr 2026 12:49:16 -0400 Subject: [PATCH 02/16] fix(jetbrains): overlay connection status above prompt --- .changeset/slow-terms-dance.md | 5 + .../ai/kilocode/client/session/SessionUi.kt | 63 +++++- .../client/session/ui/ConnectionPanel.kt | 3 +- .../client/session/SessionUiLayoutTest.kt | 191 ++++++++++++++++++ 4 files changed, 258 insertions(+), 4 deletions(-) create mode 100644 .changeset/slow-terms-dance.md create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt diff --git a/.changeset/slow-terms-dance.md b/.changeset/slow-terms-dance.md new file mode 100644 index 00000000000..777d5bec8e7 --- /dev/null +++ b/.changeset/slow-terms-dance.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Keep the JetBrains session connection status above the prompt without shifting the chat layout. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 9212f9816ce..aeaff4c2fca 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -23,7 +23,9 @@ import kotlinx.coroutines.CoroutineScope import java.awt.BorderLayout import java.awt.CardLayout import javax.swing.BoxLayout +import javax.swing.JLayeredPane import javax.swing.JPanel +import javax.swing.SwingUtilities /** * Top-level session UI — a thin composition root. @@ -34,6 +36,8 @@ import javax.swing.JPanel * [PromptPanel], [QuestionPanel], [PermissionPanel]. * - Switches between the status (loading) card and the transcript card via * [SessionControllerEvent.ViewChanged]. + * - Keeps [ConnectionPanel] on a transparent overlay layer directly above the + * prompt. * - Delegates all transcript and dock updates to the panels themselves via * [SessionModelEvent] listeners (no inline rendering logic here). * - Scrolls to the bottom on new content. @@ -99,6 +103,36 @@ class SessionUi( onAbort = { controller.abort() }, ) + private val content = JPanel(BorderLayout()) + + private val overlay = object : JPanel(null) { + override fun contains(x: Int, y: Int): Boolean { + for (child in components) { + if (child.isVisible && child.bounds.contains(x, y)) return true + } + return false + } + }.apply { + isOpaque = false + } + + private val root: JLayeredPane = object : JLayeredPane() { + override fun doLayout() { + content.setBounds(0, 0, width, height) + overlay.setBounds(0, 0, width, height) + + content.doLayout() + prompt.parent?.doLayout() + + val box = SwingUtilities.convertRectangle(prompt.parent, prompt.bounds, overlay) + val h = connection.preferredSize.height + connection.setBounds(box.x, maxOf(0, box.y - h), box.width, h) + connection.doLayout() + } + + override fun getPreferredSize() = content.preferredSize + } + init { // South area: question dock, permission dock, and prompt stacked vertically. // BoxLayout(Y_AXIS) collapses invisible panels to zero height automatically, @@ -108,7 +142,6 @@ class SessionUi( isOpaque = false add(question) add(permission) - add(connection) add(prompt) } @@ -116,8 +149,17 @@ class SessionUi( center.add(scroll, MESSAGES) cards.show(center, STATUS) - add(center, BorderLayout.CENTER) - add(south, BorderLayout.SOUTH) + content.add(center, BorderLayout.CENTER) + content.add(south, BorderLayout.SOUTH) + + overlay.add(connection) + + root.add(content) + root.setLayer(content, JLayeredPane.DEFAULT_LAYER) + root.add(overlay) + root.setLayer(overlay, JLayeredPane.PALETTE_LAYER) + + add(root, BorderLayout.CENTER) // ------ picker wiring ------ @@ -202,6 +244,7 @@ class SessionUi( permission.hidePanel() } } + refresh() scrollToBottom() } @@ -210,6 +253,20 @@ class SessionUi( bar.value = bar.maximum } + private fun refresh() { + center.revalidate() + center.repaint() + content.revalidate() + content.repaint() + root.revalidate() + root.repaint() + } + + override fun doLayout() { + super.doLayout() + root.doLayout() + } + override fun dispose() {} } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt index 34c925e9e4d..eeba3d47831 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt @@ -26,7 +26,8 @@ class ConnectionPanel( init { Disposer.register(parent, this) - isOpaque = false + isOpaque = true + background = UIUtil.getPanelBackground() add(label, BorderLayout.CENTER) controller.addListener(this, this) render() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt new file mode 100644 index 00000000000..73a2b15b1f1 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt @@ -0,0 +1,191 @@ +package ai.kilocode.client.session + +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.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.QuestionOption +import ai.kilocode.client.session.model.SessionState +import ai.kilocode.client.session.ui.ConnectionPanel +import ai.kilocode.client.session.ui.PermissionPanel +import ai.kilocode.client.session.ui.PromptPanel +import ai.kilocode.client.session.ui.QuestionPanel +import ai.kilocode.client.testing.FakeAppRpcApi +import ai.kilocode.client.testing.FakeSessionRpcApi +import ai.kilocode.client.testing.FakeWorkspaceRpcApi +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 com.intellij.testFramework.fixtures.BasePlatformTestCase +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import javax.swing.JLayeredPane +import javax.swing.JPanel +import javax.swing.SwingUtilities + +@Suppress("UnstableApiUsage") +class SessionUiLayoutTest : BasePlatformTestCase() { + + private lateinit var scope: CoroutineScope + private lateinit var sessions: KiloSessionService + private lateinit var app: KiloAppService + private lateinit var workspaces: KiloWorkspaceService + private lateinit var workspace: Workspace + private lateinit var ui: SessionUi + + override fun setUp() { + super.setUp() + scope = CoroutineScope(SupervisorJob()) + + val rpc = FakeSessionRpcApi() + val appRpc = FakeAppRpcApi().also { + it.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + } + val workspaceRpc = FakeWorkspaceRpcApi().also { + it.state.value = KiloWorkspaceStateDto(status = KiloWorkspaceStatusDto.READY) + } + + sessions = KiloSessionService(project, scope, rpc) + app = KiloAppService(scope, appRpc) + workspaces = KiloWorkspaceService(scope, workspaceRpc) + workspace = workspaces.workspace("/test") + + ui = SessionUi(project, workspace, sessions, app, scope).apply { + setSize(800, 600) + } + layout() + } + + override fun tearDown() { + try { + scope.cancel() + } finally { + super.tearDown() + } + } + + fun `test root contains content and overlay layers`() { + val root = find(ui) + + assertEquals(2, root.componentCount) + assertTrue(root.components.all { it is JPanel }) + val panels = root.components.map { it as JPanel } + val overlay = panels.first { it.components.any { child -> child is ConnectionPanel } } + val content = panels.first { it !== overlay } + assertEquals(JLayeredPane.DEFAULT_LAYER, root.getLayer(content)) + assertEquals(JLayeredPane.PALETTE_LAYER, root.getLayer(overlay)) + } + + fun `test overlay panel matches prompt width and sits above prompt`() { + val prompt = find(ui) + val connection = find(ui) + val overlay = connection.parent + + layout() + + val box = SwingUtilities.convertRectangle(prompt.parent, prompt.bounds, overlay) + val h = connection.preferredSize.height + + assertEquals(box.x, connection.x) + assertEquals(box.width, connection.width) + assertEquals(maxOf(0, box.y - h), connection.y) + assertEquals(h, connection.height) + } + + fun `test overlay follows prompt when question panel changes visibility`() { + val prompt = find(ui) + val connection = find(ui) + val overlay = connection.parent + val question = find(ui) + + layout() + assertFalse(question.isVisible) + + controller().model.setState(questionState()) + layout() + + assertTrue(question.isVisible) + val box = SwingUtilities.convertRectangle(prompt.parent, prompt.bounds, overlay) + assertEquals(box.width, connection.width) + assertEquals(maxOf(0, box.y - connection.preferredSize.height), connection.y) + assertEquals(connection.preferredSize.height, connection.height) + } + + fun `test overlay follows prompt when permission panel changes visibility`() { + val prompt = find(ui) + val connection = find(ui) + val overlay = connection.parent + val permission = find(ui) + + layout() + assertFalse(permission.isVisible) + + controller().model.setState(permissionState()) + layout() + + assertTrue(permission.isVisible) + val box = SwingUtilities.convertRectangle(prompt.parent, prompt.bounds, overlay) + assertEquals(box.width, connection.width) + assertEquals(maxOf(0, box.y - connection.preferredSize.height), connection.y) + assertEquals(connection.preferredSize.height, connection.height) + } + + private fun layout() { + ui.doLayout() + find(ui).doLayout() + } + + private inline fun find(root: java.awt.Container): T { + return find(root, T::class.java) ?: error("missing ${T::class.java.simpleName}") + } + + private fun find(root: java.awt.Container, cls: Class): T? { + if (cls.isInstance(root)) return cls.cast(root) + for (child in root.components) { + if (cls.isInstance(child)) return cls.cast(child) + if (child is java.awt.Container) { + val item = find(child, cls) + if (item != null) return item + } + } + return null + } + + private fun controller(): SessionController { + val field = SessionUi::class.java.getDeclaredField("controller") + field.isAccessible = true + return field.get(ui) as SessionController + } + + private fun questionState() = SessionState.AwaitingQuestion( + Question( + id = "q1", + items = listOf( + QuestionItem( + question = "Proceed?", + header = "Confirm", + options = listOf(QuestionOption("Yes", "Continue")), + multiple = false, + custom = true, + ) + ), + ) + ) + + private fun permissionState() = SessionState.AwaitingPermission( + Permission( + id = "p1", + sessionId = "ses", + name = "edit", + patterns = listOf("*.kt"), + always = emptyList(), + meta = PermissionMeta(raw = emptyMap()), + ) + ) +} From 16c93d2d1adcaec21ed5f0dfb9af72e95fdaf265 Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 24 Apr 2026 12:57:15 -0400 Subject: [PATCH 03/16] refactor(jetbrains): move busy state logic into model --- .../src/main/kotlin/ai/kilocode/client/session/SessionUi.kt | 5 ----- .../kotlin/ai/kilocode/client/session/model/SessionState.kt | 5 +++++ 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index aeaff4c2fca..ae489352450 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -269,8 +269,3 @@ class SessionUi( override fun dispose() {} } - -private fun SessionState.isBusy(): Boolean = when (this) { - is SessionState.Idle, is SessionState.Error -> false - else -> true -} 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 6226dde8a55..7d0f5f1b67b 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 @@ -15,4 +15,9 @@ sealed class SessionState { data class Offline(val message: String, val requestId: String) : SessionState() data class Error(val message: String, val kind: String? = null) : SessionState() + + fun isBusy(): Boolean = when (this) { + is Idle, is Error -> false + else -> true + } } From bb5ddee22127c53654c74b71058eed16cd00c36b Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 24 Apr 2026 14:13:05 -0400 Subject: [PATCH 04/16] refactor(jetbrains): isolate session update and layout helpers Move controller queue types into a dedicated update package and extract the generic layered root so SessionUi can stay focused on composition. Rename the message list panel to match its role and keep the queue condenser with the update flow. --- .../kilocode/client/app/KiloSessionService.kt | 2 +- .../ai/kilocode/client/session/SessionUi.kt | 69 ++--- .../client/session/model/SessionModel.kt | 2 +- .../client/session/ui/ConnectionPanel.kt | 6 +- .../client/session/ui/MessageListUi.kt | 282 ------------------ .../client/session/ui/PermissionPanel.kt | 2 +- .../client/session/ui/ProgressPanel.kt | 2 +- .../client/session/ui/QuestionPanel.kt | 2 +- ...ionPanel.kt => SessionMessageListPanel.kt} | 2 +- .../client/session/ui/SessionRootPanel.kt | 76 +++++ .../session/{ => update}/SessionController.kt | 30 +- .../{ => update}/SessionControllerEvent.kt | 2 +- .../{ => update}/SessionQueueCondenser.kt | 4 +- .../{ => update}/SessionUpdateQueue.kt | 2 +- .../client/session/ListenerLifecycleTest.kt | 2 +- .../session/SessionControllerTestBase.kt | 16 +- .../session/SessionQueueCondenserTest.kt | 1 + .../client/session/SessionRecoveryTest.kt | 3 +- .../client/session/SessionUiLayoutTest.kt | 17 +- .../client/session/SessionUiUpdateTest.kt | 8 +- .../client/session/SessionUpdateQueueTest.kt | 1 + .../client/session/ui/QuestionPanelTest.kt | 2 +- ...Test.kt => SessionMessageListPanelTest.kt} | 8 +- .../client/session/ui/SessionRootPanelTest.kt | 70 +++++ 24 files changed, 235 insertions(+), 376 deletions(-) delete mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListUi.kt rename packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/{SessionPanel.kt => SessionMessageListPanel.kt} (99%) create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionRootPanel.kt rename packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/{ => update}/SessionController.kt (97%) rename packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/{ => update}/SessionControllerEvent.kt (96%) rename packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/{ => update}/SessionQueueCondenser.kt (96%) rename packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/{ => update}/SessionUpdateQueue.kt (99%) rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/{SessionPanelTest.kt => SessionMessageListPanelTest.kt} (96%) create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionRootPanelTest.kt diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt index 063922fcdb4..5905fd0fa52 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt @@ -34,7 +34,7 @@ import kotlinx.coroutines.launch * Project-level frontend service for session management. * * Stateless with respect to "active session" — callers pass explicit - * session IDs. [ai.kilocode.client.session.SessionController] owns the + * session IDs. [ai.kilocode.client.session.update.SessionController] owns the * active session concept. */ @Service(Service.Level.PROJECT) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index ae489352450..81fafa5a64a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -11,7 +11,11 @@ import ai.kilocode.client.session.ui.LabelPicker import ai.kilocode.client.session.ui.PermissionPanel import ai.kilocode.client.session.ui.PromptPanel import ai.kilocode.client.session.ui.QuestionPanel -import ai.kilocode.client.session.ui.SessionPanel +import ai.kilocode.client.session.ui.SessionRootPanel +import ai.kilocode.client.session.ui.SessionMessageListPanel +import ai.kilocode.client.session.update.EVENT_FLUSH_MS +import ai.kilocode.client.session.update.SessionController +import ai.kilocode.client.session.update.SessionControllerEvent import com.intellij.openapi.Disposable import com.intellij.openapi.project.Project import com.intellij.openapi.util.registry.Registry @@ -23,7 +27,6 @@ import kotlinx.coroutines.CoroutineScope import java.awt.BorderLayout import java.awt.CardLayout import javax.swing.BoxLayout -import javax.swing.JLayeredPane import javax.swing.JPanel import javax.swing.SwingUtilities @@ -31,11 +34,11 @@ import javax.swing.SwingUtilities * Top-level session UI — a thin composition root. * * Responsibilities: - * - Creates and wires [SessionController], [SessionPanel], [EmptySessionPanel], + * - Creates and wires [ai.kilocode.client.session.update.SessionController], [SessionMessageListPanel], [EmptySessionPanel], * [ConnectionPanel], * [PromptPanel], [QuestionPanel], [PermissionPanel]. * - Switches between the status (loading) card and the transcript card via - * [SessionControllerEvent.ViewChanged]. + * [ai.kilocode.client.session.update.SessionControllerEvent.ViewChanged]. * - Keeps [ConnectionPanel] on a transparent overlay layer directly above the * prompt. * - Delegates all transcript and dock updates to the panels themselves via @@ -65,9 +68,9 @@ class SessionUi( ?: EVENT_FLUSH_MS private val controller = SessionController( - this, null, sessions, workspace, app, cs, this, - flushMs = flushMs, - condense = Registry.`is`("kilo.session.condense", true), + this, null, sessions, workspace, app, cs, this, + flushMs = flushMs, + condense = Registry.`is`("kilo.session.condense", true), ) // ------ card switch ------ @@ -81,7 +84,7 @@ class SessionUi( // ------ transcript ------ - private val transcript = SessionPanel(controller.model, this) + private val transcript = SessionMessageListPanel(controller.model, this) private val scroll = JBScrollPane(transcript).apply { border = JBUI.Borders.empty() @@ -103,34 +106,8 @@ class SessionUi( onAbort = { controller.abort() }, ) - private val content = JPanel(BorderLayout()) - - private val overlay = object : JPanel(null) { - override fun contains(x: Int, y: Int): Boolean { - for (child in components) { - if (child.isVisible && child.bounds.contains(x, y)) return true - } - return false - } - }.apply { - isOpaque = false - } - - private val root: JLayeredPane = object : JLayeredPane() { - override fun doLayout() { - content.setBounds(0, 0, width, height) - overlay.setBounds(0, 0, width, height) - - content.doLayout() - prompt.parent?.doLayout() - - val box = SwingUtilities.convertRectangle(prompt.parent, prompt.bounds, overlay) - val h = connection.preferredSize.height - connection.setBounds(box.x, maxOf(0, box.y - h), box.width, h) - connection.doLayout() - } - - override fun getPreferredSize() = content.preferredSize + private val root = SessionRootPanel().apply { + content.layout = BorderLayout() } init { @@ -149,15 +126,13 @@ class SessionUi( center.add(scroll, MESSAGES) cards.show(center, STATUS) - content.add(center, BorderLayout.CENTER) - content.add(south, BorderLayout.SOUTH) - - overlay.add(connection) - - root.add(content) - root.setLayer(content, JLayeredPane.DEFAULT_LAYER) - root.add(overlay) - root.setLayer(overlay, JLayeredPane.PALETTE_LAYER) + root.content.add(center, BorderLayout.CENTER) + root.content.add(south, BorderLayout.SOUTH) + root.addOverlay(connection) { panel, child -> + val box = SwingUtilities.convertRectangle(prompt.parent, prompt.bounds, panel) + val h = child.preferredSize.height + java.awt.Rectangle(box.x, maxOf(0, box.y - h), box.width, h) + } add(root, BorderLayout.CENTER) @@ -256,8 +231,8 @@ class SessionUi( private fun refresh() { center.revalidate() center.repaint() - content.revalidate() - content.repaint() + root.content.revalidate() + root.content.repaint() root.revalidate() root.repaint() } 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 4fec5b07bb1..0c93461c5e8 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 @@ -15,7 +15,7 @@ import com.intellij.openapi.util.Disposer /** * Pure session model — single source of truth for session content and runtime state. * - * **EDT-only access** — no synchronization. [ai.kilocode.client.session.SessionController] guarantees all + * **EDT-only access** — no synchronization. [ai.kilocode.client.session.update.SessionController] guarantees all * reads and writes happen on the EDT. * * In addition to the flat message list, the model maintains a derived diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt index eeba3d47831..5fd14f8f8a5 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt @@ -1,9 +1,9 @@ package ai.kilocode.client.session.ui import ai.kilocode.client.plugin.KiloBundle -import ai.kilocode.client.session.SessionController -import ai.kilocode.client.session.SessionControllerEvent -import ai.kilocode.client.session.SessionControllerListener +import ai.kilocode.client.session.update.SessionController +import ai.kilocode.client.session.update.SessionControllerEvent +import ai.kilocode.client.session.update.SessionControllerListener import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto import com.intellij.openapi.Disposable diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListUi.kt deleted file mode 100644 index b7e98135703..00000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListUi.kt +++ /dev/null @@ -1,282 +0,0 @@ -package ai.kilocode.client.session.ui - -import ai.kilocode.client.session.model.SessionModel -import ai.kilocode.client.session.model.SessionModelEvent -import ai.kilocode.client.session.model.SessionState -import ai.kilocode.client.session.model.Compaction -import ai.kilocode.client.session.model.Content -import ai.kilocode.client.session.model.Generic -import ai.kilocode.client.session.model.Message -import ai.kilocode.client.session.model.Reasoning -import ai.kilocode.client.session.model.Text -import ai.kilocode.client.session.model.Tool -import ai.kilocode.client.session.model.ToolExecState -import com.intellij.openapi.Disposable -import com.intellij.ui.AnimatedIcon -import com.intellij.ui.JBColor -import com.intellij.ui.components.JBLabel -import com.intellij.util.ui.JBUI -import com.intellij.util.ui.UIUtil -import java.awt.BorderLayout -import java.awt.FlowLayout -import javax.swing.BoxLayout -import javax.swing.JPanel -import javax.swing.JTextArea -import javax.swing.border.MatteBorder - -/** - * Scrollable panel displaying session messages aligned to the top, - * with an optional animated status indicator at the bottom. - * - * Passive view — all rendering is driven by [SessionModelEvent]s - * from the [SessionModel]. No public mutation methods. - */ -class MessageListUi( - parent: Disposable, - private val model: SessionModel, -) : JPanel(BorderLayout()) { - - private val blocks = LinkedHashMap() - private var errorLabel: JBLabel? = null - - private val inner = JPanel().apply { - layout = BoxLayout(this, BoxLayout.Y_AXIS) - isOpaque = false - border = JBUI.Borders.empty(4, 8) - } - - private val label = JBLabel().apply { - foreground = UIUtil.getContextHelpForeground() - } - - private val spinner = JPanel(FlowLayout(FlowLayout.LEFT, JBUI.scale(4), 0)).apply { - isOpaque = false - isVisible = false - border = JBUI.Borders.empty(6, 0) - alignmentX = LEFT_ALIGNMENT - add(JBLabel(AnimatedIcon.Default())) - add(label) - } - - init { - isOpaque = true - background = UIUtil.getPanelBackground() - inner.add(spinner) - add(inner, BorderLayout.NORTH) - - model.addListener(parent) { event -> - when (event) { - is SessionModelEvent.MessageAdded -> onAdded(event.info) - is SessionModelEvent.MessageUpdated -> onAdded(event.info) // refresh/upsert - is SessionModelEvent.MessageRemoved -> onRemoved(event.id) - is SessionModelEvent.ContentAdded -> onContentAdded(event.messageId, event.content) - is SessionModelEvent.ContentUpdated -> onContentUpdated(event.messageId, event.content) - is SessionModelEvent.ContentRemoved -> onContentRemoved(event.messageId, event.contentId) - is SessionModelEvent.ContentDelta -> onContentDelta(event.messageId, event.contentId, event.delta) - is SessionModelEvent.StateChanged -> onState(event.state) - is SessionModelEvent.HistoryLoaded -> onHistory() - is SessionModelEvent.Cleared -> onCleared() - is SessionModelEvent.DiffUpdated, - is SessionModelEvent.TodosUpdated, - is SessionModelEvent.Compacted, - is SessionModelEvent.TurnAdded, - is SessionModelEvent.TurnUpdated, - is SessionModelEvent.TurnRemoved -> Unit - } - } - } - - private fun onAdded(info: Message) { - if (blocks.containsKey(info.info.id)) return - val block = MessageBlock(info) - blocks[info.info.id] = block - inner.add(block, inner.componentCount - 1) - refresh() - } - - private fun onRemoved(id: String) { - val block = blocks.remove(id) ?: return - inner.remove(block) - refresh() - } - - private fun onContentAdded(messageId: String, content: Content) { - blocks[messageId]?.addContent(content) - refresh() - } - - private fun onContentUpdated(messageId: String, content: Content) { - blocks[messageId]?.updateContent(content) - refresh() - } - - private fun onContentRemoved(messageId: String, contentId: String) { - blocks[messageId]?.removeContent(contentId) - refresh() - } - - private fun onContentDelta(messageId: String, contentId: String, delta: String) { - blocks[messageId]?.appendDelta(contentId, delta) - refresh() - } - - private fun onState(state: SessionState) { - errorLabel?.let { inner.remove(it); errorLabel = null } - when (state) { - is SessionState.Busy -> { - label.text = state.text - spinner.isVisible = true - } - is SessionState.Error -> { - spinner.isVisible = false - val err = JBLabel(state.message).apply { - foreground = JBColor.RED - font = JBUI.Fonts.label() - border = JBUI.Borders.empty(4, 0) - alignmentX = LEFT_ALIGNMENT - } - errorLabel = err - inner.add(err, inner.componentCount - 1) - } - else -> { - spinner.isVisible = false - } - } - refresh() - } - - private fun onHistory() { - clear() - for (entry in model.messages()) { - val block = MessageBlock(entry) - blocks[entry.info.id] = block - inner.add(block, inner.componentCount - 1) - for ((_, content) in entry.parts) block.addContent(content) - } - refresh() - } - - private fun onCleared() { - clear() - refresh() - } - - private fun clear() { - blocks.clear() - errorLabel = null - inner.removeAll() - inner.add(spinner) - spinner.isVisible = false - } - - private fun refresh() { - revalidate() - repaint() - } -} - -private class MessageBlock(info: Message) : JPanel() { - private val areas = LinkedHashMap() - private val labels = LinkedHashMap() - - init { - layout = BoxLayout(this, BoxLayout.Y_AXIS) - isOpaque = false - alignmentX = LEFT_ALIGNMENT - - border = if (info.info.role == "user") { - JBUI.Borders.compound( - MatteBorder(1, 0, 0, 0, JBColor.border()), - JBUI.Borders.empty(8, 0, 4, 0), - ) - } else { - JBUI.Borders.empty(4, 0) - } - } - - fun addContent(content: Content) { - when (content) { - is Text -> { - val area = createArea() - if (content.content.isNotEmpty()) area.text = content.content.toString() - areas[content.id] = area - add(area) - } - is Reasoning -> { - val area = createArea().apply { - foreground = UIUtil.getContextHelpForeground() - } - if (content.content.isNotEmpty()) area.text = content.content.toString() - areas[content.id] = area - add(area) - } - is Tool -> { - val lbl = createToolLabel(content) - labels[content.id] = lbl - add(lbl) - } - is Compaction -> { - val lbl = JBLabel("Context compacted").apply { - foreground = UIUtil.getContextHelpForeground() - font = JBUI.Fonts.smallFont() - border = JBUI.Borders.empty(4, 0) - alignmentX = LEFT_ALIGNMENT - } - labels[content.id] = lbl - add(lbl) - } - is Generic -> {} // unknown part type — not rendered in this pass - } - revalidate() - } - - fun removeContent(contentId: String) { - areas.remove(contentId)?.let { remove(it) } - labels.remove(contentId)?.let { remove(it) } - revalidate() - } - - fun updateContent(content: Content) { - when (content) { - is Text -> areas[content.id]?.text = content.content.toString() - is Reasoning -> areas[content.id]?.text = content.content.toString() - is Tool -> labels[content.id]?.text = toolText(content) - is Compaction -> {} - is Generic -> {} - } - revalidate() - } - - fun appendDelta(contentId: String, delta: String) { - areas[contentId]?.append(delta) - revalidate() - } - - private fun createArea() = JTextArea().apply { - isEditable = false - lineWrap = true - wrapStyleWord = true - isOpaque = false - font = JBUI.Fonts.label() - foreground = UIUtil.getLabelForeground() - border = JBUI.Borders.empty() - alignmentX = LEFT_ALIGNMENT - } - - private fun createToolLabel(content: Tool) = JBLabel(toolText(content)).apply { - foreground = UIUtil.getContextHelpForeground() - font = JBUI.Fonts.smallFont() - border = JBUI.Borders.empty(2, 0) - alignmentX = LEFT_ALIGNMENT - } - - private fun toolText(content: Tool): String { - val icon = when (content.state) { - ToolExecState.PENDING -> "\u23F3" - ToolExecState.RUNNING -> "\u25B6" - ToolExecState.COMPLETED -> "\u2713" - ToolExecState.ERROR -> "\u2717" - } - return "$icon ${content.title ?: content.name}" - } -} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/PermissionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/PermissionPanel.kt index 0c105e5da22..64319963508 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/PermissionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/PermissionPanel.kt @@ -1,6 +1,6 @@ package ai.kilocode.client.session.ui -import ai.kilocode.client.session.SessionController +import ai.kilocode.client.session.update.SessionController import ai.kilocode.client.session.model.Permission import ai.kilocode.rpc.dto.PermissionReplyDto import com.intellij.icons.AllIcons 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 75619241f77..3dea6a3beaf 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 @@ -18,7 +18,7 @@ import java.awt.FlowLayout * - [SessionState.Busy] → shows an animated spinner and [SessionState.Busy.text] * - Any other state → hidden * - * Owned by [SessionPanel], which always re-anchors it as the last child so it + * Owned by [SessionMessageListPanel], which always re-anchors it as the last child so it * appears below all turn views inside the scroll pane. */ class ProgressPanel( diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/QuestionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/QuestionPanel.kt index fcd83a223c1..709f5b08448 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/QuestionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/QuestionPanel.kt @@ -1,6 +1,6 @@ package ai.kilocode.client.session.ui -import ai.kilocode.client.session.SessionController +import ai.kilocode.client.session.update.SessionController import ai.kilocode.client.session.model.Question import ai.kilocode.rpc.dto.QuestionReplyDto import com.intellij.ui.JBColor diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt similarity index 99% rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionPanel.kt rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt index 7a44c4531ef..4708c5a4d49 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt @@ -28,7 +28,7 @@ import com.intellij.util.ui.JBUI * * All method calls must happen on the EDT. */ -class SessionPanel( +class SessionMessageListPanel( private val model: SessionModel, parent: Disposable, ) : SessionLayoutPanel() { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionRootPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionRootPanel.kt new file mode 100644 index 00000000000..0c13740eb17 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionRootPanel.kt @@ -0,0 +1,76 @@ +package ai.kilocode.client.session.ui + +import java.awt.Dimension +import java.awt.Rectangle +import javax.swing.JComponent +import javax.swing.JLayeredPane +import javax.swing.JPanel + +class SessionRootPanel : JLayeredPane() { + + val content = JPanel() + + val overlay = Overlay() + + init { + layout = null + add(content) + setLayer(content, DEFAULT_LAYER) + add(overlay) + setLayer(overlay, PALETTE_LAYER) + } + + fun addOverlay(child: JComponent, bounds: (JPanel, JComponent) -> Rectangle) { + overlay.addOverlay(child, bounds) + } + + override fun doLayout() { + components + .sortedBy { getLayer(it) } + .forEach { child -> + child.setBounds(0, 0, width, height) + child.doLayout() + } + } + + override fun getPreferredSize(): Dimension { + val w = components.maxOfOrNull { it.preferredSize.width } ?: 0 + val h = components.maxOfOrNull { it.preferredSize.height } ?: 0 + return Dimension(w, h) + } + + class Overlay : JPanel(null) { + + private val items = linkedMapOf Rectangle>() + + init { + isOpaque = false + } + + fun addOverlay(child: JComponent, bounds: (JPanel, JComponent) -> Rectangle) { + items[child] = bounds + add(child) + } + + override fun contains(x: Int, y: Int): Boolean { + for (child in components) { + if (child.isVisible && child.bounds.contains(x, y)) return true + } + return false + } + + override fun doLayout() { + items.forEach { (child, bounds) -> + child.bounds = bounds(this, child) + child.doLayout() + } + } + + override fun getPreferredSize(): Dimension { + val pref = super.getPreferredSize() + val w = maxOf(pref.width, components.maxOfOrNull { it.preferredSize.width } ?: 0) + val h = maxOf(pref.height, components.maxOfOrNull { it.preferredSize.height } ?: 0) + return Dimension(w, h) + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt similarity index 97% rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionController.kt rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt index bdc6303385f..edacb192472 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloSessionService @@ -35,6 +35,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.cancel import kotlinx.coroutines.launch +import java.awt.Component /** * Session lifecycle orchestrator for a single session. @@ -49,15 +50,15 @@ import kotlinx.coroutines.launch * via [SessionControllerEvent] to registered listeners. */ class SessionController( - parent: Disposable, - id: String?, - private val sessions: KiloSessionService, - private val workspace: Workspace, - private val app: KiloAppService, - private val cs: CoroutineScope, - comp: java.awt.Component? = null, - private val flushMs: Long = EVENT_FLUSH_MS, - private val condense: Boolean = true, + parent: Disposable, + id: String?, + private val sessions: KiloSessionService, + private val workspace: Workspace, + private val app: KiloAppService, + private val cs: CoroutineScope, + comp: Component? = null, + private val flushMs: Long = EVENT_FLUSH_MS, + private val condense: Boolean = true, ) : Disposable { companion object { @@ -73,7 +74,14 @@ class SessionController( private val listeners = mutableListOf() private var sessionId: String? = id private val directory: String get() = workspace.directory - private val updates = SessionUpdateQueue(parent, comp, flushMs, ::handle, condense, id != null) { sessionId ?: "pending" } + private val updates = SessionUpdateQueue( + parent, + comp, + flushMs, + ::handle, + condense, + id != null + ) { sessionId ?: "pending" } private var partType: String? = null private var tool: String? = null diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionControllerEvent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionControllerEvent.kt similarity index 96% rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionControllerEvent.kt rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionControllerEvent.kt index 6026aed5e28..b872969180f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionControllerEvent.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionControllerEvent.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update import ai.kilocode.client.session.model.SessionModel import ai.kilocode.client.session.model.SessionModelEvent diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionQueueCondenser.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionQueueCondenser.kt similarity index 96% rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionQueueCondenser.kt rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionQueueCondenser.kt index cb6b85d7667..dcb06f4e6ca 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionQueueCondenser.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionQueueCondenser.kt @@ -1,9 +1,9 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update import ai.kilocode.rpc.dto.ChatEventDto /** - * Reduces a batch of queued [ChatEventDto] events before they are flushed to + * Reduces a batch of queued [ai.kilocode.rpc.dto.ChatEventDto] events before they are flushed to * the model, by merging consecutive same-key snapshot and text-delta events. * * ## Algorithm diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUpdateQueue.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionUpdateQueue.kt similarity index 99% rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUpdateQueue.kt rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionUpdateQueue.kt index 2c02e7b3349..5881e1bff08 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUpdateQueue.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionUpdateQueue.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update import ai.kilocode.log.ChatLogSummary import ai.kilocode.log.KiloLog diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ListenerLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ListenerLifecycleTest.kt index a9a7de5c9df..f45af5a2ebe 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ListenerLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ListenerLifecycleTest.kt @@ -1,7 +1,7 @@ package ai.kilocode.client.session -import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.client.session.model.SessionState +import ai.kilocode.client.session.update.SessionControllerEvent import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.SessionStatusDto import com.intellij.openapi.util.Disposer diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt index 8e79cec7dc9..83688adb057 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt @@ -10,6 +10,8 @@ import ai.kilocode.client.testing.FakeWorkspaceRpcApi import ai.kilocode.client.testing.FakeSessionRpcApi import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.app.Workspace +import ai.kilocode.client.session.update.SessionController +import ai.kilocode.client.session.update.SessionControllerEvent import ai.kilocode.rpc.dto.AgentDto import ai.kilocode.rpc.dto.AgentsDto import ai.kilocode.rpc.dto.ChatEventDto @@ -36,7 +38,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking /** - * Base class for [SessionController] tests. + * Base class for [ai.kilocode.client.session.update.SessionController] tests. * * Provides real IntelliJ Application/EDT/Disposer via [BasePlatformTestCase], * real frontend services wired to fake RPC backends, and shared helpers. @@ -128,7 +130,17 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { protected fun controller(id: String? = null, flushMs: Long, condense: Boolean): SessionController { val root = Root() - val m = SessionController(parent, id, sessions, workspace, app, scope, root, flushMs, condense) + val m = SessionController( + parent, + id, + sessions, + workspace, + app, + scope, + root, + flushMs, + condense + ) controllers.add(m) roots[m] = root return m diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionQueueCondenserTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionQueueCondenserTest.kt index 0e5cf03e171..f114099717f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionQueueCondenserTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionQueueCondenserTest.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.session +import ai.kilocode.client.session.update.SessionQueueCondenser import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.DiffFileDto import ai.kilocode.rpc.dto.MessageDto diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionRecoveryTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionRecoveryTest.kt index 87d68d4ff32..05af93de749 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionRecoveryTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionRecoveryTest.kt @@ -5,13 +5,12 @@ import ai.kilocode.rpc.dto.PermissionRequestDto import ai.kilocode.rpc.dto.QuestionInfoDto import ai.kilocode.rpc.dto.QuestionRequestDto import ai.kilocode.rpc.dto.SessionStatusDto -import ai.kilocode.rpc.dto.SessionTimeDto /** * Tests for pending permission/question recovery after history load. * * VS Code rehydrates pending prompts by calling list endpoints after - * reconnect. JetBrains now does the same in [SessionController.recoverPending]. + * reconnect. JetBrains now does the same in [ai.kilocode.client.session.update.SessionController.recoverPending]. */ class SessionRecoveryTest : SessionControllerTestBase() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt index 73a2b15b1f1..7522c71aa94 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt @@ -14,6 +14,8 @@ import ai.kilocode.client.session.ui.ConnectionPanel import ai.kilocode.client.session.ui.PermissionPanel import ai.kilocode.client.session.ui.PromptPanel import ai.kilocode.client.session.ui.QuestionPanel +import ai.kilocode.client.session.ui.SessionRootPanel +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 @@ -26,7 +28,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import javax.swing.JLayeredPane -import javax.swing.JPanel import javax.swing.SwingUtilities @Suppress("UnstableApiUsage") @@ -71,15 +72,13 @@ class SessionUiLayoutTest : BasePlatformTestCase() { } fun `test root contains content and overlay layers`() { - val root = find(ui) + val root = find(ui) assertEquals(2, root.componentCount) - assertTrue(root.components.all { it is JPanel }) - val panels = root.components.map { it as JPanel } - val overlay = panels.first { it.components.any { child -> child is ConnectionPanel } } - val content = panels.first { it !== overlay } - assertEquals(JLayeredPane.DEFAULT_LAYER, root.getLayer(content)) - assertEquals(JLayeredPane.PALETTE_LAYER, root.getLayer(overlay)) + assertSame(root.content, root.components.first { it === root.content }) + assertSame(root.overlay, root.components.first { it === root.overlay }) + assertEquals(JLayeredPane.DEFAULT_LAYER, root.getLayer(root.content)) + assertEquals(JLayeredPane.PALETTE_LAYER, root.getLayer(root.overlay)) } fun `test overlay panel matches prompt width and sits above prompt`() { @@ -138,7 +137,7 @@ class SessionUiLayoutTest : BasePlatformTestCase() { private fun layout() { ui.doLayout() - find(ui).doLayout() + find(ui).doLayout() } private inline fun find(root: java.awt.Container): T { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiUpdateTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiUpdateTest.kt index 343271cdf7f..c5044e1b342 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiUpdateTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiUpdateTest.kt @@ -2,7 +2,7 @@ package ai.kilocode.client.session import ai.kilocode.client.session.model.SessionModel import ai.kilocode.client.session.model.SessionState -import ai.kilocode.client.session.ui.SessionPanel +import ai.kilocode.client.session.ui.SessionMessageListPanel import ai.kilocode.client.session.views.TextView import ai.kilocode.rpc.dto.MessageDto import ai.kilocode.rpc.dto.MessageTimeDto @@ -14,7 +14,7 @@ import com.intellij.testFramework.fixtures.BasePlatformTestCase /** * Integration test: mutate [SessionModel] directly on the EDT and verify - * that [SessionPanel] reflects the changes without any end-to-end RPC flow. + * that [SessionMessageListPanel] reflects the changes without any end-to-end RPC flow. * * This tests the full model → event → view update pipeline in isolation. */ @@ -23,13 +23,13 @@ class SessionUiUpdateTest : BasePlatformTestCase() { private lateinit var model: SessionModel private lateinit var parent: Disposable - private lateinit var panel: SessionPanel + private lateinit var panel: SessionMessageListPanel override fun setUp() { super.setUp() parent = Disposer.newDisposable("test") model = SessionModel() - panel = SessionPanel(model, parent) + panel = SessionMessageListPanel(model, parent) } override fun tearDown() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUpdateQueueTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUpdateQueueTest.kt index 04dc2ad4f3e..ed084a1584e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUpdateQueueTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUpdateQueueTest.kt @@ -4,6 +4,7 @@ import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.client.session.model.SessionState +import ai.kilocode.client.session.update.SessionController import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.DiffFileDto import ai.kilocode.rpc.dto.SessionStatusDto diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/QuestionPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/QuestionPanelTest.kt index 9f16b995293..769b5dbbe08 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/QuestionPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/QuestionPanelTest.kt @@ -4,7 +4,7 @@ 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.session.SessionController +import ai.kilocode.client.session.update.SessionController import ai.kilocode.client.session.model.Question import ai.kilocode.client.session.model.QuestionItem import ai.kilocode.client.session.model.QuestionOption diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt similarity index 96% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionPanelTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt index a0df7614d7a..c2f2a68858e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt @@ -11,24 +11,24 @@ import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase /** - * Tests for [SessionPanel] — structural and index integrity. + * Tests for [SessionMessageListPanel] — structural and index integrity. * * Uses [BasePlatformTestCase] for a real IntelliJ Application; layout * is not measured (no screen), but the structural / index state is fully * testable. */ @Suppress("UnstableApiUsage") -class SessionPanelTest : BasePlatformTestCase() { +class SessionMessageListPanelTest : BasePlatformTestCase() { private lateinit var model: SessionModel private lateinit var parent: Disposable - private lateinit var panel: SessionPanel + private lateinit var panel: SessionMessageListPanel override fun setUp() { super.setUp() parent = Disposer.newDisposable("test") model = SessionModel() - panel = SessionPanel(model, parent) + panel = SessionMessageListPanel(model, parent) } override fun tearDown() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionRootPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionRootPanelTest.kt new file mode 100644 index 00000000000..7d66c8604f1 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionRootPanelTest.kt @@ -0,0 +1,70 @@ +package ai.kilocode.client.session.ui + +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.awt.Dimension +import java.awt.Rectangle +import javax.swing.JLayeredPane +import javax.swing.JPanel + +@Suppress("UnstableApiUsage") +class SessionRootPanelTest : BasePlatformTestCase() { + + fun `test root owns content and overlay layers`() { + val root = SessionRootPanel() + + assertEquals(2, root.componentCount) + assertSame(root.content, root.components.first { it === root.content }) + assertSame(root.overlay, root.components.first { it === root.overlay }) + assertEquals(JLayeredPane.DEFAULT_LAYER, root.getLayer(root.content)) + assertEquals(JLayeredPane.PALETTE_LAYER, root.getLayer(root.overlay)) + } + + fun `test root layout fills immediate children`() { + val root = SessionRootPanel().apply { + setSize(320, 180) + } + + root.doLayout() + + assertEquals(Rectangle(0, 0, 320, 180), root.content.bounds) + assertEquals(Rectangle(0, 0, 320, 180), root.overlay.bounds) + } + + fun `test root preferred size is max of immediate children`() { + val root = SessionRootPanel().apply { + content.preferredSize = Dimension(300, 120) + overlay.preferredSize = Dimension(180, 220) + } + + assertEquals(Dimension(300, 220), root.preferredSize) + } + + fun `test addOverlay applies callback bounds and delegates child layout`() { + val root = SessionRootPanel().apply { + setSize(400, 260) + } + val child = Probe() + + root.addOverlay(child) { _, item -> + Rectangle(12, 34, item.preferredSize.width, item.preferredSize.height) + } + + root.doLayout() + + assertEquals(Rectangle(12, 34, 80, 24), child.bounds) + assertTrue(child.laid) + } + + private class Probe : JPanel() { + var laid = false + + init { + preferredSize = Dimension(80, 24) + } + + override fun doLayout() { + laid = true + super.doLayout() + } + } +} From a63d77a4f7da83f4df9eb569bd744a9e888b7513 Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 24 Apr 2026 15:04:54 -0400 Subject: [PATCH 05/16] refactor: streamline jetbrains session UI and tests Keep SessionUi construction phased and align the session test tree with the production package layout so the UI and update flow are easier to navigate. Also pin Kotlin editorconfig defaults so IDE formatting matches repo expectations. --- .editorconfig | 5 + .../ai/kilocode/client/session/SessionUi.kt | 185 ++++++++++-------- .../client/session/ui/SessionRootPanel.kt | 3 +- .../client/session/SessionUiLayoutTest.kt | 8 +- .../session/{ => ui}/ProgressPanelTest.kt | 3 +- .../session/{ => ui}/SessionUiUpdateTest.kt | 3 +- .../session/{ => update}/AppWatchingTest.kt | 2 +- .../{ => update}/ChatLoggingFlowTest.kt | 2 +- .../{ => update}/ConfigSelectionTest.kt | 2 +- .../{ => update}/HistoryLoadingTest.kt | 2 +- .../{ => update}/ListenerLifecycleTest.kt | 3 +- .../session/{ => update}/MessageListTest.kt | 2 +- .../{ => update}/ProgressTrackingTest.kt | 2 +- .../{ => update}/PromptLifecycleTest.kt | 2 +- .../{ => update}/SessionArtifactsTest.kt | 2 +- .../{ => update}/SessionControllerTestBase.kt | 2 +- .../{ => update}/SessionCreationTest.kt | 2 +- .../{ => update}/SessionQueueCondenserTest.kt | 3 +- .../{ => update}/SessionRecoveryTest.kt | 2 +- .../{ => update}/SessionUpdateQueueTest.kt | 3 +- .../{ => update}/StatusComputationTest.kt | 2 +- .../session/{ => update}/TurnLifecycleTest.kt | 2 +- .../session/{ => update}/ViewSwitchingTest.kt | 2 +- .../{ => update}/WorkspaceWatchingTest.kt | 2 +- 24 files changed, 129 insertions(+), 117 deletions(-) rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/{ => ui}/ProgressPanelTest.kt (96%) rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/{ => ui}/SessionUiUpdateTest.kt (98%) rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/{ => update}/AppWatchingTest.kt (93%) rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/{ => update}/ChatLoggingFlowTest.kt (98%) rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/{ => update}/ConfigSelectionTest.kt (96%) rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/{ => update}/HistoryLoadingTest.kt (96%) rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/{ => update}/ListenerLifecycleTest.kt (97%) rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/{ => update}/MessageListTest.kt (99%) rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/{ => update}/ProgressTrackingTest.kt (99%) rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/{ => update}/PromptLifecycleTest.kt (99%) rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/{ => update}/SessionArtifactsTest.kt (99%) rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/{ => update}/SessionControllerTestBase.kt (99%) rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/{ => update}/SessionCreationTest.kt (96%) rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/{ => update}/SessionQueueCondenserTest.kt (99%) rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/{ => update}/SessionRecoveryTest.kt (99%) rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/{ => update}/SessionUpdateQueueTest.kt (99%) rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/{ => update}/StatusComputationTest.kt (97%) rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/{ => update}/TurnLifecycleTest.kt (99%) rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/{ => update}/ViewSwitchingTest.kt (94%) rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/{ => update}/WorkspaceWatchingTest.kt (96%) diff --git a/.editorconfig b/.editorconfig index aada95f26a8..35ebc0b32db 100644 --- a/.editorconfig +++ b/.editorconfig @@ -7,3 +7,8 @@ end_of_line = lf indent_style = space indent_size = 2 max_line_length = 80 + +[*.{kt,kts}] +indent_size = 4 +tab_width = 4 +ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 81fafa5a64a..5096b65daaa 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -24,29 +24,21 @@ import ai.kilocode.log.KiloLog import com.intellij.ui.components.JBScrollPane import com.intellij.util.ui.JBUI import kotlinx.coroutines.CoroutineScope +import java.awt.Component import java.awt.BorderLayout -import java.awt.CardLayout import javax.swing.BoxLayout +import javax.swing.BoxLayout.Y_AXIS import javax.swing.JPanel import javax.swing.SwingUtilities /** - * Top-level session UI — a thin composition root. + * Top-level session UI composition root. * - * Responsibilities: - * - Creates and wires [ai.kilocode.client.session.update.SessionController], [SessionMessageListPanel], [EmptySessionPanel], - * [ConnectionPanel], - * [PromptPanel], [QuestionPanel], [PermissionPanel]. - * - Switches between the status (loading) card and the transcript card via - * [ai.kilocode.client.session.update.SessionControllerEvent.ViewChanged]. - * - Keeps [ConnectionPanel] on a transparent overlay layer directly above the - * prompt. - * - Delegates all transcript and dock updates to the panels themselves via - * [SessionModelEvent] listeners (no inline rendering logic here). - * - Scrolls to the bottom on new content. + * It builds the session panels, wires controller/model listeners, and swaps the + * center body between the empty state and the message list. * - * Views must never call RPC or services directly; everything goes through - * the controller. + * The only custom layout logic kept here is the prompt-relative connection + * overlay. All user actions still flow through [SessionController]. */ class SessionUi( project: Project, @@ -57,108 +49,127 @@ class SessionUi( ) : JPanel(BorderLayout()), Disposable { companion object { - private const val STATUS = "status" - private const val MESSAGES = "messages" private val LOG = KiloLog.create(SessionUi::class.java) } - private val flushMs = Registry.intValue("kilo.session.flushMs", EVENT_FLUSH_MS.toInt()) - .takeIf { it > 0 } - ?.toLong() - ?: EVENT_FLUSH_MS + private val project = project + + private val flushMs = + Registry.intValue("kilo.session.flushMs", EVENT_FLUSH_MS.toInt()) + .takeIf { it > 0 } + ?.toLong() + ?: EVENT_FLUSH_MS private val controller = SessionController( - this, null, sessions, workspace, app, cs, this, - flushMs = flushMs, - condense = Registry.`is`("kilo.session.condense", true), + this, null, sessions, workspace, app, cs, this, + flushMs = flushMs, + condense = Registry.`is`("kilo.session.condense", true), ) - // ------ card switch ------ - private val cards = CardLayout() - private val center = JPanel(cards) + private lateinit var root: SessionRootPanel - // ------ status (loading) panel ------ + private lateinit var sessionContent: JPanel - private val status = EmptySessionPanel(this) + private lateinit var emptyBody: EmptySessionPanel - // ------ transcript ------ + private lateinit var messageBody: SessionMessageListPanel - private val transcript = SessionMessageListPanel(controller.model, this) + private lateinit var messageScroll: JBScrollPane - private val scroll = JBScrollPane(transcript).apply { - border = JBUI.Borders.empty() - verticalScrollBarPolicy = JBScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED - horizontalScrollBarPolicy = JBScrollPane.HORIZONTAL_SCROLLBAR_NEVER - } + private lateinit var question: QuestionPanel + private lateinit var permission: PermissionPanel + private lateinit var connection: ConnectionPanel - // ------ dock panels (above prompt) ------ + private lateinit var prompt: PromptPanel - private val question = QuestionPanel(controller) - private val permission = PermissionPanel(controller) - private val connection = ConnectionPanel(this, controller) - // ------ prompt ------ + init { + buildUi() + bindUi() + showBody(emptyBody) + } - private val prompt = PromptPanel( - project = project, - onSend = { text -> send(text) }, - onAbort = { controller.abort() }, - ) + private fun buildUi() { + root = SessionRootPanel() - private val root = SessionRootPanel().apply { - content.layout = BorderLayout() - } + sessionContent = JPanel(BorderLayout()) - init { - // South area: question dock, permission dock, and prompt stacked vertically. - // BoxLayout(Y_AXIS) collapses invisible panels to zero height automatically, - // so hiding a dock doesn't leave an empty gap above the prompt. - val south = JPanel().apply { - layout = BoxLayout(this, BoxLayout.Y_AXIS) - isOpaque = false + emptyBody = EmptySessionPanel(this) + messageBody = SessionMessageListPanel(controller.model, this) + + messageScroll = JBScrollPane(messageBody).apply { + border = JBUI.Borders.empty() + verticalScrollBarPolicy = JBScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED + horizontalScrollBarPolicy = JBScrollPane.HORIZONTAL_SCROLLBAR_NEVER + } + question = QuestionPanel(controller) + permission = PermissionPanel(controller) + connection = ConnectionPanel(this, controller) + + prompt = PromptPanel( + project = project, + onSend = { text -> sendPrompot(text) }, + onAbort = { controller.abort() }, + ) + + root.content.add(sessionContent, BorderLayout.CENTER) + // Question and permission panels stay in normal flow so the prompt moves + // naturally when either dock appears. + root.content.add(JPanel().apply { + this.layout = BoxLayout(this, Y_AXIS) add(question) add(permission) add(prompt) - } - - center.add(status, STATUS) - center.add(scroll, MESSAGES) - cards.show(center, STATUS) + }, BorderLayout.SOUTH) - root.content.add(center, BorderLayout.CENTER) - root.content.add(south, BorderLayout.SOUTH) + // Keep connection status visually attached to the prompt without taking + // space in the south stack. root.addOverlay(connection) { panel, child -> - val box = SwingUtilities.convertRectangle(prompt.parent, prompt.bounds, panel) + val box = SwingUtilities.convertRectangle( + prompt.parent, + prompt.bounds, + panel + ) val h = child.preferredSize.height java.awt.Rectangle(box.x, maxOf(0, box.y - h), box.width, h) } add(root, BorderLayout.CENTER) + } - // ------ picker wiring ------ - + private fun bindUi() { prompt.mode.onSelect = { item -> controller.selectAgent(item.id) } prompt.model.onSelect = picker@{ item -> val group = item.group ?: return@picker controller.selectModel(group, item.id) } - // ------ controller lifecycle events ------ - controller.addListener(this) { event -> when (event) { is SessionControllerEvent.WorkspaceReady -> { val m = controller.model - prompt.mode.setItems(m.agents.map { LabelPicker.Item(it.name, it.display) }, m.agent) - val items = m.models.map { LabelPicker.Item(it.id, it.display, it.provider) } - val selected = m.model?.let { full -> items.firstOrNull { "${it.group}/${it.id}" == full }?.id } + prompt.mode.setItems(m.agents.map { + LabelPicker.Item( + it.name, + it.display + ) + }, m.agent) + val items = m.models.map { + LabelPicker.Item( + it.id, + it.display, + it.provider + ) + } + val selected = + m.model?.let { full -> items.firstOrNull { "${it.group}/${it.id}" == full }?.id } prompt.model.setItems(items, selected) prompt.setReady(m.isReady()) } is SessionControllerEvent.ViewChanged -> - cards.show(center, if (event.show) MESSAGES else STATUS) + showBody(if (event.show) messageScroll else emptyBody) is SessionControllerEvent.AppChanged, is SessionControllerEvent.WorkspaceChanged -> @@ -166,11 +177,9 @@ class SessionUi( } } - // ------ model events — prompt state + dock + auto-scroll ------ - controller.model.addListener(this) { event -> when (event) { - is SessionModelEvent.StateChanged -> onState(event.state) + is SessionModelEvent.StateChanged -> onStateChanged(event.state) is SessionModelEvent.TurnAdded, is SessionModelEvent.TurnUpdated, @@ -192,9 +201,7 @@ class SessionUi( } } - // ------ private helpers ------ - - private fun send(text: String) { + private fun sendPrompot(text: String) { if (text.isBlank()) return LOG.debug { "${ChatLogSummary.prompt(text)} agent=${controller.model.agent ?: "none"} model=${controller.model.model ?: "none"} ready=${controller.ready}" @@ -203,17 +210,19 @@ class SessionUi( prompt.clear() } - private fun onState(state: SessionState) { + private fun onStateChanged(state: SessionState) { prompt.setBusy(state.isBusy()) when (state) { is SessionState.AwaitingQuestion -> { permission.hidePanel() question.show(state.question) } + is SessionState.AwaitingPermission -> { question.hidePanel() permission.show(state.permission) } + else -> { question.hidePanel() permission.hidePanel() @@ -224,22 +233,24 @@ class SessionUi( } private fun scrollToBottom() { - val bar = scroll.verticalScrollBar + val bar = messageScroll.verticalScrollBar bar.value = bar.maximum } private fun refresh() { - center.revalidate() - center.repaint() - root.content.revalidate() - root.content.repaint() root.revalidate() root.repaint() } - override fun doLayout() { - super.doLayout() - root.doLayout() + private fun showBody(panel: Component) { + if (sessionContent.getComponentCount() == 1 && sessionContent.getComponent( + 0 + ) === panel + ) return + sessionContent.removeAll() + sessionContent.add(panel, BorderLayout.CENTER) + sessionContent.revalidate() + sessionContent.repaint() } override fun dispose() {} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionRootPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionRootPanel.kt index 0c13740eb17..cb22774bc6b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionRootPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionRootPanel.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.session.ui +import java.awt.BorderLayout import java.awt.Dimension import java.awt.Rectangle import javax.swing.JComponent @@ -8,7 +9,7 @@ import javax.swing.JPanel class SessionRootPanel : JLayeredPane() { - val content = JPanel() + val content = JPanel(BorderLayout()) val overlay = Overlay() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt index 7522c71aa94..ed3ebcdbb78 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt @@ -106,7 +106,7 @@ class SessionUiLayoutTest : BasePlatformTestCase() { layout() assertFalse(question.isVisible) - controller().model.setState(questionState()) + controller().model.setState(questionStateChanged()) layout() assertTrue(question.isVisible) @@ -125,7 +125,7 @@ class SessionUiLayoutTest : BasePlatformTestCase() { layout() assertFalse(permission.isVisible) - controller().model.setState(permissionState()) + controller().model.setState(permissionStateChanged()) layout() assertTrue(permission.isVisible) @@ -162,7 +162,7 @@ class SessionUiLayoutTest : BasePlatformTestCase() { return field.get(ui) as SessionController } - private fun questionState() = SessionState.AwaitingQuestion( + private fun questionStateChanged() = SessionState.AwaitingQuestion( Question( id = "q1", items = listOf( @@ -177,7 +177,7 @@ class SessionUiLayoutTest : BasePlatformTestCase() { ) ) - private fun permissionState() = SessionState.AwaitingPermission( + private fun permissionStateChanged() = SessionState.AwaitingPermission( Permission( id = "p1", sessionId = "ses", diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ProgressPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ProgressPanelTest.kt similarity index 96% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ProgressPanelTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ProgressPanelTest.kt index 2162a5df508..271a89e5617 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ProgressPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ProgressPanelTest.kt @@ -1,10 +1,9 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.ui import ai.kilocode.client.session.model.Permission import ai.kilocode.client.session.model.PermissionMeta import ai.kilocode.client.session.model.SessionModel import ai.kilocode.client.session.model.SessionState -import ai.kilocode.client.session.ui.ProgressPanel import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiUpdateTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt similarity index 98% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiUpdateTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt index c5044e1b342..18f6888bf89 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiUpdateTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt @@ -1,8 +1,7 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.ui import ai.kilocode.client.session.model.SessionModel import ai.kilocode.client.session.model.SessionState -import ai.kilocode.client.session.ui.SessionMessageListPanel import ai.kilocode.client.session.views.TextView import ai.kilocode.rpc.dto.MessageDto import ai.kilocode.rpc.dto.MessageTimeDto diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/AppWatchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/AppWatchingTest.kt similarity index 93% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/AppWatchingTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/AppWatchingTest.kt index 157642dc922..521c5b2bb7a 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/AppWatchingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/AppWatchingTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.KiloAppStatusDto diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ChatLoggingFlowTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ChatLoggingFlowTest.kt similarity index 98% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ChatLoggingFlowTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ChatLoggingFlowTest.kt index 3a8e1f0c777..a2852df403f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ChatLoggingFlowTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ChatLoggingFlowTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.client.session.model.SessionState diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ConfigSelectionTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ConfigSelectionTest.kt similarity index 96% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ConfigSelectionTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ConfigSelectionTest.kt index eb53354c488..ae79e031dcd 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ConfigSelectionTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ConfigSelectionTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update class ConfigSelectionTest : SessionControllerTestBase() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/HistoryLoadingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/HistoryLoadingTest.kt similarity index 96% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/HistoryLoadingTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/HistoryLoadingTest.kt index 6a892a4a5f1..3f7acda9ed6 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/HistoryLoadingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/HistoryLoadingTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.rpc.dto.MessageWithPartsDto diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ListenerLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ListenerLifecycleTest.kt similarity index 97% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ListenerLifecycleTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ListenerLifecycleTest.kt index f45af5a2ebe..75800512232 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ListenerLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ListenerLifecycleTest.kt @@ -1,7 +1,6 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update import ai.kilocode.client.session.model.SessionState -import ai.kilocode.client.session.update.SessionControllerEvent import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.SessionStatusDto import com.intellij.openapi.util.Disposer diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/MessageListTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/MessageListTest.kt similarity index 99% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/MessageListTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/MessageListTest.kt index a53b0655c16..b09da2064e1 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/MessageListTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/MessageListTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.rpc.dto.ChatEventDto diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ProgressTrackingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ProgressTrackingTest.kt similarity index 99% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ProgressTrackingTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ProgressTrackingTest.kt index f4db69ddd54..03fd591a5a1 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ProgressTrackingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ProgressTrackingTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.client.session.model.SessionState diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/PromptLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/PromptLifecycleTest.kt similarity index 99% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/PromptLifecycleTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/PromptLifecycleTest.kt index 38cb5abf77b..8e3cea3ba9f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/PromptLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/PromptLifecycleTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update import ai.kilocode.client.session.model.SessionState import ai.kilocode.rpc.dto.ChatEventDto diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionArtifactsTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionArtifactsTest.kt similarity index 99% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionArtifactsTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionArtifactsTest.kt index e25d544235a..d2afd1387e5 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionArtifactsTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionArtifactsTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.DiffFileDto diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionControllerTestBase.kt similarity index 99% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionControllerTestBase.kt index 83688adb057..0ed6891bf8f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionControllerTestBase.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloSessionService diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionCreationTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionCreationTest.kt similarity index 96% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionCreationTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionCreationTest.kt index ac7d2c9a944..f89654996d5 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionCreationTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionCreationTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update class SessionCreationTest : SessionControllerTestBase() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionQueueCondenserTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionQueueCondenserTest.kt similarity index 99% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionQueueCondenserTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionQueueCondenserTest.kt index f114099717f..c7871d4332e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionQueueCondenserTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionQueueCondenserTest.kt @@ -1,6 +1,5 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update -import ai.kilocode.client.session.update.SessionQueueCondenser import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.DiffFileDto import ai.kilocode.rpc.dto.MessageDto diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionRecoveryTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionRecoveryTest.kt similarity index 99% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionRecoveryTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionRecoveryTest.kt index 05af93de749..e407fb61b49 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionRecoveryTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionRecoveryTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update import ai.kilocode.client.session.model.SessionState import ai.kilocode.rpc.dto.PermissionRequestDto diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUpdateQueueTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionUpdateQueueTest.kt similarity index 99% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUpdateQueueTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionUpdateQueueTest.kt index ed084a1584e..a204f24dc39 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUpdateQueueTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionUpdateQueueTest.kt @@ -1,10 +1,9 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.client.session.model.SessionState -import ai.kilocode.client.session.update.SessionController import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.DiffFileDto import ai.kilocode.rpc.dto.SessionStatusDto diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/StatusComputationTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/StatusComputationTest.kt similarity index 97% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/StatusComputationTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/StatusComputationTest.kt index 98719ec3c36..27adf704416 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/StatusComputationTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/StatusComputationTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.rpc.dto.ChatEventDto diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/TurnLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/TurnLifecycleTest.kt similarity index 99% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/TurnLifecycleTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/TurnLifecycleTest.kt index 317271b1a60..80aa8367511 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/TurnLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/TurnLifecycleTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update import ai.kilocode.client.session.model.SessionState import ai.kilocode.rpc.dto.ChatEventDto diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ViewSwitchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ViewSwitchingTest.kt similarity index 94% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ViewSwitchingTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ViewSwitchingTest.kt index 66f687eb884..2edc4f7fbb9 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ViewSwitchingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ViewSwitchingTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update class ViewSwitchingTest : SessionControllerTestBase() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/WorkspaceWatchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/WorkspaceWatchingTest.kt similarity index 96% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/WorkspaceWatchingTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/WorkspaceWatchingTest.kt index 92ba9f79785..2aa2ce3f66c 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/WorkspaceWatchingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/WorkspaceWatchingTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update class WorkspaceWatchingTest : SessionControllerTestBase() { From 9a0419d1b1991cfeb63b9695795d0d25cfdf3190 Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 24 Apr 2026 15:07:17 -0400 Subject: [PATCH 06/16] refactor(jetbrains): polish session ui naming --- .../main/kotlin/ai/kilocode/client/session/SessionUi.kt | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 5096b65daaa..5f948b60cda 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -36,9 +36,6 @@ import javax.swing.SwingUtilities * * It builds the session panels, wires controller/model listeners, and swaps the * center body between the empty state and the message list. - * - * The only custom layout logic kept here is the prompt-relative connection - * overlay. All user actions still flow through [SessionController]. */ class SessionUi( project: Project, @@ -109,7 +106,7 @@ class SessionUi( prompt = PromptPanel( project = project, - onSend = { text -> sendPrompot(text) }, + onSend = { text -> sendPrompt(text) }, onAbort = { controller.abort() }, ) @@ -201,7 +198,7 @@ class SessionUi( } } - private fun sendPrompot(text: String) { + private fun sendPrompt(text: String) { if (text.isBlank()) return LOG.debug { "${ChatLogSummary.prompt(text)} agent=${controller.model.agent ?: "none"} model=${controller.model.model ?: "none"} ready=${controller.ready}" From f8e31e0b6daa6e0cadcbf70dbe9f0436e1c0e245 Mon Sep 17 00:00:00 2001 From: kirillk Date: Sun, 26 Apr 2026 13:59:33 -0400 Subject: [PATCH 07/16] fix(jetbrains): make connection panel recovery reliable Surface startup, workspace, and config issues in the panel so sessions explain why they are unavailable. Let retry refresh warnings first and restart the app when problems persist, while logging final error and warning states on the backend. --- .changeset/green-planes-reply.md | 5 + .../ai/kilocode/backend/app/KiloAppState.kt | 7 + .../backend/app/KiloBackendAppService.kt | 146 +++++++++++-- .../app/KiloBackendConnectionService.kt | 11 +- .../kilocode/backend/rpc/KiloAppRpcApiImpl.kt | 11 + .../backend/workspace/KiloBackendWorkspace.kt | 19 +- .../backend/app/KiloBackendAppServiceTest.kt | 183 +++++++++++++++- .../backend/app/KiloConnectionServiceTest.kt | 3 +- .../kilocode/backend/testing/MockCliServer.kt | 3 + .../workspace/KiloBackendWorkspaceTest.kt | 1 + .../ai/kilocode/client/app/KiloAppService.kt | 10 + .../client/app/KiloWorkspaceService.kt | 2 +- .../ai/kilocode/client/app/Workspace.kt | 1 + .../client/session/ui/ConnectionPanel.kt | 198 +++++++++++++++++- .../session/update/SessionController.kt | 19 ++ .../resources/messages/KiloBundle.properties | 2 + .../client/session/ui/ConnectionPanelTest.kt | 153 ++++++++++++++ .../client/session/update/AppWatchingTest.kt | 45 ++++ .../kilocode/client/testing/FakeAppRpcApi.kt | 7 + .../client/testing/FakeWorkspaceRpcApi.kt | 3 + .../kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt | 3 + .../ai/kilocode/rpc/dto/KiloAppStateDto.kt | 8 + 22 files changed, 803 insertions(+), 37 deletions(-) create mode 100644 .changeset/green-planes-reply.md create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt diff --git a/.changeset/green-planes-reply.md b/.changeset/green-planes-reply.md new file mode 100644 index 00000000000..f9b3517d9c4 --- /dev/null +++ b/.changeset/green-planes-reply.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Show detailed JetBrains session connection and configuration issues in the connection panel, and make retry recover app-side problems more reliably. diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloAppState.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloAppState.kt index 220c11c9b96..7bb9c069e10 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloAppState.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloAppState.kt @@ -40,6 +40,12 @@ data class LoadError( val detail: String? = null, ) +data class ConfigWarning( + val path: String, + val message: String, + val detail: String? = null, +) + /** * All global data that has been successfully loaded. * Present only in [KiloAppState.Ready]. @@ -48,4 +54,5 @@ data class AppData( val profile: KiloProfile200Response?, val config: Config, val notifications: List, + val warnings: List = emptyList(), ) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt index 9f36144d284..59b7285db2b 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt @@ -10,6 +10,7 @@ import ai.kilocode.jetbrains.api.infrastructure.ClientException import ai.kilocode.jetbrains.api.infrastructure.ServerError import ai.kilocode.jetbrains.api.infrastructure.ServerException import ai.kilocode.jetbrains.api.model.Config +import ai.kilocode.jetbrains.api.model.ConfigWarnings200ResponseInner import ai.kilocode.jetbrains.api.model.KiloNotifications200ResponseInner import ai.kilocode.jetbrains.api.model.KiloProfile200Response import ai.kilocode.rpc.dto.HealthDto @@ -25,6 +26,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import okhttp3.OkHttpClient @@ -107,6 +109,9 @@ class KiloBackendAppService private constructor( @Volatile var notifications: List = emptyList() private set + @Volatile var warnings: List = emptyList() + private set + suspend fun connect() { mutex.withLock { val current = _appState.value @@ -130,6 +135,42 @@ class KiloBackendAppService private constructor( } } + suspend fun retry() { + mutex.withLock { + when (val current = _appState.value) { + KiloAppState.Disconnected -> { + ensureWatcher() + connection.connect() + } + KiloAppState.Connecting, + is KiloAppState.Loading -> Unit + is KiloAppState.Ready -> { + if (current.data.warnings.isEmpty()) return + log.info("retry: refreshing config warnings") + refreshConfigState() + val next = _appState.value + val warns = (next as? KiloAppState.Ready)?.data?.warnings + if (next is KiloAppState.Ready && warns.isNullOrEmpty()) return + restartConnection("warnings remained after refresh") + } + is KiloAppState.Error -> { + val load = current.errors.none { it.resource == "connection" } + if (load && connection.api != null) { + log.info("retry: rerunning app load from ${current.message}") + val prev = _appState.value + load() + val next = awaitLoadResult(prev) + val warns = (next as? KiloAppState.Ready)?.data?.warnings + if (next is KiloAppState.Ready && warns.isNullOrEmpty()) return + restartConnection("state remained problematic after load retry") + return + } + restartConnection("connection error: ${current.message}") + } + } + } + } + /** One-shot health check via the generated API client. */ suspend fun health(): HealthDto { val client = api ?: throw IllegalStateException("Not connected") @@ -157,7 +198,12 @@ class KiloBackendAppService private constructor( ConnectionState.Disconnected -> _appState.value = KiloAppState.Disconnected ConnectionState.Connecting -> _appState.value = KiloAppState.Connecting is ConnectionState.Connected -> load() - is ConnectionState.Error -> _appState.value = KiloAppState.Error(next.message) + is ConnectionState.Error -> setAppError( + message = next.message, + errors = next.details?.let { + listOf(LoadError(resource = "connection", detail = it)) + } ?: emptyList(), + ) } } } @@ -186,6 +232,7 @@ class KiloBackendAppService private constructor( var cfg: Config? = null var prof: KiloProfile200Response? = null var notifs: List = emptyList() + var warns: List = emptyList() try { coroutineScope { @@ -229,6 +276,9 @@ class KiloBackendAppService private constructor( throw LoadFailure(err) } } + launch { + warns = fetchWarnings() + } } ensureActive() @@ -238,11 +288,12 @@ class KiloBackendAppService private constructor( sessions.start(connection.api!!, connection.apiClient!!, connection.port, connection.events) chat.start(connection.apiClient!!, connection.port, connection.events) workspaces.start(connection.api!!, connection.events) - _appState.value = KiloAppState.Ready( + setAppReady( AppData( profile = prof, config = cfg!!, notifications = notifs, + warnings = warns, ) ) log.info("Application started — config, profile, notifications loaded") @@ -251,7 +302,7 @@ class KiloBackendAppService private constructor( throw e } catch (e: Exception) { log.warn("Application start failed: ${e.message}") - _appState.value = KiloAppState.Error( + setAppError( message = "Failed to load required data", errors = errors.toList(), ) @@ -321,6 +372,81 @@ class KiloBackendAppService private constructor( } } + private suspend fun fetchWarnings(): List { + val client = connection.api ?: return emptyList() + return try { + client.configWarnings().map(::warning) + } catch (e: Exception) { + log.warn("Config warnings fetch failed: ${e.message}", e) + emptyList() + } + } + + private fun warning(w: ConfigWarnings200ResponseInner) = ConfigWarning( + path = w.path, + message = w.message, + detail = w.detail, + ) + + private suspend fun refreshConfigState() { + val current = _appState.value as? KiloAppState.Ready ?: return + val cfg = fetchConfig().value ?: return + val warns = fetchWarnings() + config = cfg + setAppReady( + current.data.copy( + config = cfg, + warnings = warns, + ) + ) + } + + private fun setAppReady(data: AppData) { + warnings = data.warnings + _appState.value = KiloAppState.Ready(data) + if (data.warnings.isNotEmpty()) warnAppWarnings(data.warnings) + } + + private fun setAppError(message: String, errors: List) { + val state = KiloAppState.Error(message, errors) + _appState.value = state + warnAppError(state) + } + + private fun warnAppError(state: KiloAppState.Error) { + val text = if (state.errors.isEmpty()) state.message + else "${state.message} [${state.errors.joinToString("; ") { error(it) }}]" + log.warn("App error: $text") + } + + private fun warnAppWarnings(warnings: List) { + val text = warnings.joinToString("; ") { warning(it) } + log.warn("App warnings: $text") + } + + private fun error(err: LoadError): String { + val status = err.status?.let { " status=$it" } ?: "" + val detail = err.detail?.let { " detail=$it" } ?: "" + return "${err.resource}$status$detail" + } + + private fun warning(warn: ConfigWarning): String { + val detail = warn.detail?.let { " detail=$it" } ?: "" + return "${warn.path}: ${warn.message}$detail" + } + + private suspend fun restartConnection(reason: String) { + clear() + connection.restart() + log.info("retry: restarted connection ($reason)") + } + + private suspend fun awaitLoadResult(prev: KiloAppState): KiloAppState { + val next = appState.first { it !== prev } + if (next !is KiloAppState.Loading) return next + return appState.first { it !is KiloAppState.Loading } + } + /** * Dump the HTTP response body from a failed API call for debugging. * The generated client wraps the response in [ClientException.response] @@ -380,17 +506,8 @@ class KiloBackendAppService private constructor( "global.config.updated" -> { log.info("SSE global.config.updated — reloading config") launch { - val result = fetchConfig() - if (result.value != null) { - config = result.value - val current = _appState.value - if (current is KiloAppState.Ready) { - _appState.value = current.copy( - data = current.data.copy(config = result.value) - ) - } - log.info("Config reloaded successfully") - } + refreshConfigState() + log.info("Config reloaded successfully") } } "global.disposed" -> { @@ -420,6 +537,7 @@ class KiloBackendAppService private constructor( profile = null config = null notifications = emptyList() + warnings = emptyList() _appState.value = KiloAppState.Disconnected } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt index e64249c3db1..d9a891cbfce 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt @@ -33,7 +33,7 @@ sealed class ConnectionState { data object Disconnected : ConnectionState() data object Connecting : ConnectionState() data class Connected(val port: Int, val password: String) : ConnectionState() - data class Error(val message: String) : ConnectionState() + data class Error(val message: String, val details: String? = null) : ConnectionState() } data class SseEvent(val type: String, val data: String) @@ -165,7 +165,7 @@ class KiloConnectionService( val result = server.init() if (result is CliServer.State.Error) { - setState(ConnectionState.Error(result.message)) + setState(ConnectionState.Error(result.message, result.details)) return } @@ -230,12 +230,17 @@ class KiloConnectionService( } override fun onFailure(src: EventSource, t: Throwable?, response: Response?) { + val detail = when { + t != null -> t.stackTraceToString() + response != null -> response.body?.string() + else -> null + }?.trim()?.ifEmpty { null } if (t != null) { log.warn("SSE: failure (${t.message}) — scheduling reconnect") } else { log.warn("SSE: failure (HTTP ${response?.code}) — scheduling reconnect") } - setState(ConnectionState.Error(t?.message ?: "SSE connection failed (HTTP ${response?.code})")) + setState(ConnectionState.Error(t?.message ?: "SSE connection failed (HTTP ${response?.code})", detail)) scheduleReconnect() } } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt index e11f315a3fd..86420b6fc4a 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt @@ -4,10 +4,12 @@ package ai.kilocode.backend.rpc import ai.kilocode.backend.app.KiloAppState import ai.kilocode.backend.app.KiloBackendAppService +import ai.kilocode.backend.app.ConfigWarning import ai.kilocode.backend.app.LoadError import ai.kilocode.backend.app.LoadProgress import ai.kilocode.backend.app.ProfileResult import ai.kilocode.rpc.KiloAppRpcApi +import ai.kilocode.rpc.dto.ConfigWarningDto import ai.kilocode.rpc.dto.HealthDto import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.KiloAppStatusDto @@ -36,6 +38,8 @@ class KiloAppRpcApiImpl : KiloAppRpcApi { override suspend fun health(): HealthDto = app.health() + override suspend fun retry() = app.retry() + override suspend fun restart() = app.restart() override suspend fun reinstall() = app.reinstall() @@ -56,6 +60,7 @@ class KiloAppRpcApiImpl : KiloAppRpcApi { profile = if (state.data.profile != null) ProfileStatusDto.LOADED else ProfileStatusDto.NOT_LOGGED_IN, ), + warnings = state.data.warnings.map(::warning), ) is KiloAppState.Error -> KiloAppStateDto( status = KiloAppStatusDto.ERROR, @@ -79,4 +84,10 @@ class KiloAppRpcApiImpl : KiloAppRpcApi { status = e.status, detail = e.detail, ) + + private fun warning(w: ConfigWarning) = ConfigWarningDto( + path = w.path, + message = w.message, + detail = w.detail, + ) } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt index f46f6715c9e..460bc9b4fe0 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt @@ -129,9 +129,7 @@ class KiloBackendWorkspace( throw e } catch (e: Exception) { log.warn("Workspace data load failed for $directory: ${e.message}") - _state.value = KiloWorkspaceState.Error( - "Failed to load: ${synchronized(errors) { errors.joinToString() }}" - ) + setWorkspaceError("Failed to load: ${synchronized(errors) { errors.joinToString() }}") } } } @@ -285,15 +283,20 @@ class KiloBackendWorkspace( ): T? { repeat(MAX_RETRIES) { attempt -> val result = block() - if (result != null) return result - if (attempt < MAX_RETRIES - 1) { - log.warn("$name: attempt ${attempt + 1}/$MAX_RETRIES failed — retrying in ${RETRY_DELAY_MS}ms") - delay(RETRY_DELAY_MS) - } + if (result != null) return result + if (attempt < MAX_RETRIES - 1) { + log.warn("$name: attempt ${attempt + 1}/$MAX_RETRIES failed — retrying in ${RETRY_DELAY_MS}ms") + delay(RETRY_DELAY_MS) + } } log.error("$name: all $MAX_RETRIES attempts failed") return null } + private fun setWorkspaceError(message: String) { + _state.value = KiloWorkspaceState.Error(message) + log.warn("Workspace error [$directory]: $message") + } + private class LoadFailure(resource: String) : Exception("Failed to load $resource") } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt index e9b6671b9f6..a736b3ddee8 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt @@ -22,6 +22,7 @@ import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue +import kotlin.test.assertContains class KiloBackendAppServiceTest { @@ -66,6 +67,72 @@ class KiloBackendAppServiceTest { assertEquals("claude-4", svc.config!!.model) } + @Test + fun `config warnings are loaded without blocking Ready`() = runBlocking { + mock.warnings = """[{"path":".kilo/kilo.json","message":"Invalid JSON","detail":"CloseBraceExpected"}]""" + val svc = create() + svc.connect() + + withTimeout(10_000) { + svc.appState.first { it is KiloAppState.Ready } + } + + val ready = svc.appState.value as KiloAppState.Ready + assertEquals(1, ready.data.warnings.size) + assertEquals(".kilo/kilo.json", ready.data.warnings.first().path) + assertEquals("Invalid JSON", ready.data.warnings.first().message) + } + + @Test + fun `retry refreshes warnings while Ready`() = runBlocking { + mock.warnings = """[{"path":".kilo/kilo.json","message":"Invalid JSON","detail":"CloseBraceExpected"}]""" + val svc = create() + svc.connect() + + withTimeout(10_000) { + svc.appState.first { it is KiloAppState.Ready } + } + + val before = svc.appState.value as KiloAppState.Ready + assertEquals(1, before.data.warnings.size) + + mock.warnings = "[]" + svc.retry() + + withTimeout(5_000) { + while ((svc.appState.value as? KiloAppState.Ready)?.data?.warnings?.isNotEmpty() == true) { + delay(100) + } + } + + val ready = svc.appState.value as KiloAppState.Ready + assertTrue(ready.data.warnings.isEmpty()) + assertTrue(svc.warnings.isEmpty()) + } + + @Test + fun `retry restarts app when warnings remain after refresh`() = runBlocking { + mock.warnings = """[{"path":".kilo/kilo.json","message":"Invalid JSON","detail":"CloseBraceExpected"}]""" + val svc = create() + svc.connect() + + withTimeout(10_000) { + svc.appState.first { it is KiloAppState.Ready } + } + + val before = mock.requestCount("/global/config") + svc.retry() + + withTimeout(15_000) { + while (mock.requestCount("/global/config") <= before) { + delay(100) + } + } + + assertTrue(mock.requestCount("/global/config") > before) + assertTrue(log.messages.any { it.contains("retry: restarted connection") }) + } + @Test fun `profile is loaded when available`() = runBlocking { mock.profile = """{"profile":{"email":"alice@test.com","name":"Alice"},"balance":null,"currentOrgId":null}""" @@ -126,6 +193,89 @@ class KiloBackendAppServiceTest { assertTrue(err.errors.any { it.resource == "notifications" }) } + @Test + fun `retry reruns load for app load error`() = runBlocking { + mock.configStatus = 500 + mock.config = """{"error":"internal"}""" + val svc = create() + svc.connect() + + withTimeout(15_000) { + svc.appState.first { it is KiloAppState.Error } + } + + assertEquals(3, mock.requestCount("/global/config")) + + mock.configStatus = 200 + mock.config = """{"model":"retry/model"}""" + svc.retry() + + withTimeout(15_000) { + svc.appState.first { it is KiloAppState.Ready } + } + + assertEquals("retry/model", svc.config?.model) + assertEquals(4, mock.requestCount("/global/config")) + } + + @Test + fun `connection error surfaces details as connection load error`() = runBlocking { + val failing = object : ai.kilocode.backend.cli.CliServer { + override var forceExtract = false + override fun process(): Process? = null + override suspend fun init() = ai.kilocode.backend.cli.CliServer.State.Error( + message = "CLI startup failed", + details = "stderr: missing dependency", + ) + override fun exited(proc: Process) {} + override fun stop() {} + override fun dispose() {} + } + val svc = KiloBackendAppService.create(scope, failing, log) + svc.connect() + + withTimeout(5_000) { + svc.appState.first { it is KiloAppState.Error } + } + + val err = svc.appState.value as KiloAppState.Error + assertEquals("CLI startup failed", err.message) + assertContains(err.errors.map { it.resource }, "connection") + assertEquals("stderr: missing dependency", err.errors.first { it.resource == "connection" }.detail) + assertTrue(log.messages.any { it.contains("App error: CLI startup failed") }) + } + + @Test + fun `warning state emits final warn log`() = runBlocking { + mock.warnings = """[{"path":".kilo/kilo.json","message":"Invalid JSON","detail":"CloseBraceExpected"}]""" + val svc = create() + svc.connect() + + withTimeout(10_000) { + svc.appState.first { it is KiloAppState.Ready } + } + + assertTrue(log.messages.any { + it.contains("App warnings:") && it.contains(".kilo/kilo.json: Invalid JSON") + }) + } + + @Test + fun `app load error emits final warn log`() = runBlocking { + mock.configStatus = 500 + mock.config = """{"error":"internal"}""" + val svc = create() + svc.connect() + + withTimeout(15_000) { + svc.appState.first { it is KiloAppState.Error } + } + + assertTrue(log.messages.any { + it.contains("App error: Failed to load required data") && it.contains("config") + }) + } + @Test fun `connect when already Ready is no-op`() = runBlocking { val svc = create() @@ -170,18 +320,18 @@ class KiloBackendAppServiceTest { } @Test - fun `profile 500 transitions to Error`() = runBlocking { + fun `profile 500 does not prevent Ready`() = runBlocking { mock.profileStatus = 500 mock.profile = """{"error":"internal"}""" val svc = create() svc.connect() withTimeout(15_000) { - svc.appState.first { it is KiloAppState.Error } + svc.appState.first { it is KiloAppState.Ready } } - val err = svc.appState.value as KiloAppState.Error - assertTrue(err.errors.any { it.resource == "profile" }) + assertNull(svc.profile) + assertIs(svc.appState.value) } @Test @@ -247,6 +397,31 @@ class KiloBackendAppServiceTest { assertEquals("updated", svc.config?.model) } + @Test + fun `SSE config updated refreshes warnings`() = runBlocking { + mock.warnings = """[{"path":".kilo/kilo.json","message":"Invalid JSON","detail":"CloseBraceExpected"}]""" + val svc = create() + svc.connect() + + withTimeout(10_000) { + svc.appState.first { it is KiloAppState.Ready } + } + + assertEquals(1, (svc.appState.value as KiloAppState.Ready).data.warnings.size) + + mock.warnings = "[]" + mock.awaitSseConnection() + mock.pushEvent("global.config.updated", """{"type":"global.config.updated"}""") + + withTimeout(5_000) { + while ((svc.appState.value as? KiloAppState.Ready)?.data?.warnings?.isNotEmpty() == true) { + delay(100) + } + } + + assertTrue((svc.appState.value as KiloAppState.Ready).data.warnings.isEmpty()) + } + // ------ Concurrency & lifecycle tests ------ @Test diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloConnectionServiceTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloConnectionServiceTest.kt index 71d75f474c5..de84bfced22 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloConnectionServiceTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloConnectionServiceTest.kt @@ -117,7 +117,7 @@ class KiloConnectionServiceTest { val failing = object : CliServer { override var forceExtract = false override fun process(): Process? = null - override suspend fun init() = CliServer.State.Error("binary not found") + override suspend fun init() = CliServer.State.Error("binary not found", "stderr line 1\nstderr line 2") override fun exited(proc: Process) {} override fun stop() {} override fun dispose() {} @@ -131,6 +131,7 @@ class KiloConnectionServiceTest { } val err = svc.state.value as ConnectionState.Error assertEquals("binary not found", err.message) + assertEquals("stderr line 1\nstderr line 2", err.details) } @Test diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt index 60b27d1fa9a..5bde260ff31 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt @@ -30,10 +30,12 @@ class MockCliServer : AutoCloseable { // Configurable REST responses — can be changed between requests @Volatile var health = """{"healthy":true,"version":"1.0.0"}""" @Volatile var config = """{"model":"test/model"}""" + @Volatile var warnings = "[]" @Volatile var notifications = "[]" @Volatile var profile = """{"profile":{"email":"test@test.com","name":"Test"},"balance":null,"currentOrgId":null}""" @Volatile var profileStatus = 200 @Volatile var configStatus = 200 + @Volatile var warningsStatus = 200 @Volatile var notificationsStatus = 200 // Project-scoped REST responses @@ -184,6 +186,7 @@ class MockCliServer : AutoCloseable { when { path == "/global/health" -> respond(output, 200, health) path == "/global/config" -> respond(output, configStatus, config) + path.startsWith("/config/warnings") -> respond(output, warningsStatus, warnings) path.startsWith("/kilo/notifications") -> respond(output, notificationsStatus, notifications) path.startsWith("/kilo/profile") -> { if (profileStatus == 401) { diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt index f1f6c38ebf4..9da0fa6cd88 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt @@ -179,6 +179,7 @@ class KiloBackendWorkspaceTest { val err = ws.state.value as KiloWorkspaceState.Error assertTrue(err.message.contains("providers")) + assertTrue(log.messages.any { it.contains("Workspace error [/test/project]: Failed to load:") && it.contains("providers") }) } @Test diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt index 01b9e201e3e..878d0a393db 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt @@ -73,6 +73,11 @@ class KiloAppService internal constructor( null } + suspend fun retry() { + LOG.info("retry: sending RPC") + call { retry() } + } + /** Kill the CLI process and restart it. */ suspend fun restart() { LOG.info("restart: resetting state and sending RPC") @@ -97,6 +102,11 @@ class KiloAppService internal constructor( cs.launch { restart() } } + fun retryAsync() { + LOG.info("retryAsync: launching retry") + cs.launch { retry() } + } + /** Fire-and-forget reinstall from non-suspend context (e.g. action handlers). */ fun reinstallAsync() { LOG.info("reinstallAsync: launching reinstall") diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt index 0909d2a4d74..b63964f745d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt @@ -66,7 +66,7 @@ class KiloWorkspaceService internal constructor( LOG.info("Creating workspace for $directory") val state = stream { state(directory) } .stateIn(cs, SharingStarted.Eagerly, INIT) - Workspace(directory, state) + Workspace(directory, state) { reload(directory) } } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/Workspace.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/Workspace.kt index 7f7cd961814..a490a267ec1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/Workspace.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/Workspace.kt @@ -13,4 +13,5 @@ import kotlinx.coroutines.flow.StateFlow class Workspace( val directory: String, val state: StateFlow, + val reload: () -> Unit, ) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt index 5fd14f8f8a5..be9554f4359 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt @@ -4,31 +4,96 @@ import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.update.SessionController import ai.kilocode.client.session.update.SessionControllerEvent import ai.kilocode.client.session.update.SessionControllerListener +import ai.kilocode.rpc.dto.ConfigWarningDto +import ai.kilocode.rpc.dto.LoadErrorDto import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import com.intellij.icons.AllIcons import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer +import com.intellij.ui.components.ActionLink import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBScrollPane +import com.intellij.ui.components.JBTextArea import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil import java.awt.BorderLayout +import java.awt.Cursor +import java.awt.Dimension +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent import javax.swing.JPanel +import javax.swing.ScrollPaneConstants class ConnectionPanel( parent: Disposable, private val controller: SessionController, ) : JPanel(BorderLayout()), SessionControllerListener, Disposable { - private val label = JBLabel().apply { + private val click = object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + flip() + } + } + + private val header = JPanel(BorderLayout()).apply { border = JBUI.Borders.empty(4, 8) + isOpaque = false + } + + private val left = JPanel(BorderLayout(JBUI.scale(4), 0)).apply { + isOpaque = false + addMouseListener(click) + } + + private val toggle = JBLabel().apply { + isVisible = false + addMouseListener(click) + } + + private val label = JBLabel().apply { foreground = UIUtil.getContextHelpForeground() + addMouseListener(click) } + private val retry = ActionLink(KiloBundle.message("session.connection.retry")) { + controller.retryConnection() + }.apply { + isVisible = false + horizontalAlignment = JBLabel.RIGHT + isFocusable = false + setRequestFocusEnabled(false) + } + + private val details = JBTextArea().apply { + isEditable = false + isOpaque = false + lineWrap = true + wrapStyleWord = true + foreground = UIUtil.getContextHelpForeground() + } + + private val scroll = JBScrollPane(details).apply { + border = JBUI.Borders.empty(0, 8, 4, 24) + isOpaque = false + viewport.isOpaque = false + horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED + isVisible = false + } + + private var detail: String? = null + private var expanded = false + init { Disposer.register(parent, this) isOpaque = true background = UIUtil.getPanelBackground() - add(label, BorderLayout.CENTER) + left.add(toggle, BorderLayout.WEST) + left.add(label, BorderLayout.CENTER) + header.add(left, BorderLayout.CENTER) + header.add(retry, BorderLayout.EAST) + add(header, BorderLayout.NORTH) controller.addListener(this, this) render() } @@ -47,15 +112,22 @@ class ConnectionPanel( val workspace = controller.model.workspace if (app.status == KiloAppStatusDto.ERROR) { - label.foreground = UIUtil.getErrorForeground() - label.text = app.error ?: KiloBundle.message("session.connection.error.unknown") + showError( + app.error ?: KiloBundle.message("session.connection.error.unknown"), + app.errors.toErrorText(), + ) showPanel() return } if (workspace.status == KiloWorkspaceStatusDto.ERROR) { - label.foreground = UIUtil.getErrorForeground() - label.text = workspace.error ?: KiloBundle.message("session.connection.error.unknown") + showError(workspace.error ?: KiloBundle.message("session.connection.error.unknown"), null) + showPanel() + return + } + + if (app.status == KiloAppStatusDto.READY && workspace.status == KiloWorkspaceStatusDto.READY && app.warnings.isNotEmpty()) { + showWarning(summary(app.warnings.size), app.warnings.toWarningText()) showPanel() return } @@ -67,9 +139,61 @@ class ConnectionPanel( label.foreground = UIUtil.getContextHelpForeground() label.text = KiloBundle.message("session.connection.connecting") + detail = null + expanded = false + toggle.isVisible = false + retry.isVisible = false + renderDetails() showPanel() } + private fun showError(text: String, detail: String?) { + label.foreground = UIUtil.getErrorForeground() + label.text = text + retry.isVisible = true + this.detail = detail?.takeIf { it.isNotBlank() } + expanded = false + toggle.isVisible = this.detail != null + renderDetails() + } + + private fun showWarning(text: String, detail: String?) { + label.foreground = UIUtil.getContextHelpForeground() + label.text = text + retry.isVisible = true + this.detail = detail?.takeIf { it.isNotBlank() } + expanded = false + toggle.isVisible = this.detail != null + renderDetails() + } + + private fun summary(count: Int): String { + val base = KiloBundle.message("session.connection.warning.config") + if (count <= 1) return base + return "$base ($count)" + } + + private fun renderDetails() { + val text = detail + val show = expanded && text != null + val cursor = if (text != null) Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) else Cursor.getDefaultCursor() + toggle.icon = if (expanded) AllIcons.General.ArrowDown else AllIcons.General.ArrowRight + left.cursor = cursor + label.cursor = cursor + toggle.cursor = cursor + details.text = text ?: "" + scroll.isVisible = show + if (show) add(scroll, BorderLayout.CENTER) + else remove(scroll) + } + + private fun flip() { + if (!toggle.isVisible) return + expanded = !expanded + renderDetails() + refresh() + } + private fun showPanel() { if (!isVisible) { isVisible = true @@ -98,4 +222,66 @@ class ConnectionPanel( override fun dispose() { // no-op } + + override fun getPreferredSize(): Dimension { + val size = super.getPreferredSize() + if (!scroll.isVisible) return size + val rows = details.getFontMetrics(details.font).height * 5 + val insets = scroll.insets.top + scroll.insets.bottom + scroll.horizontalScrollBar.preferredSize.height + val height = rows + insets + JBUI.scale(2) + return Dimension(size.width, maxOf(size.height, header.preferredSize.height + height)) + } + + internal fun summaryText() = label.text + + internal fun detailsText() = details.text + + internal fun retryVisible() = retry.isVisible + + internal fun detailsVisible() = scroll.isVisible + + internal fun toggleVisible() = toggle.isVisible + + internal fun toggleExpanded() = expanded + + internal fun clickToggle() { + if (!toggle.isVisible) return + toggle.mouseListeners.firstOrNull()?.mouseClicked( + MouseEvent(toggle, MouseEvent.MOUSE_CLICKED, 0, 0, 0, 0, 1, false) + ) + } + + internal fun clickSummary() { + label.mouseListeners.firstOrNull()?.mouseClicked( + MouseEvent(label, MouseEvent.MOUSE_CLICKED, 0, 0, 0, 0, 1, false) + ) + } + + internal fun retryFocusable() = retry.isFocusable + + internal fun clickRetry() = retry.doClick() +} + +private fun List.toErrorText(): String? { + val out = mapNotNull { it.toDetailLine() } + if (out.isEmpty()) return null + return out.joinToString("\n") +} + +private fun List.toWarningText(): String? { + val out = mapNotNull { it.toDetailLine() } + if (out.isEmpty()) return null + return out.joinToString("\n\n") +} + +private fun LoadErrorDto.toDetailLine(): String? { + val detail = detail?.trim()?.ifEmpty { null } ?: return null + if (resource == "connection") return detail + return "$resource: $detail" +} + +private fun ConfigWarningDto.toDetailLine(): String { + val head = "$path: $message" + val tail = detail?.trim()?.ifEmpty { null } ?: return head + return "$head\n$tail" } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt index edacb192472..09a280cad03 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt @@ -135,6 +135,25 @@ class SessionController( } } + fun retryConnection() { + LOG.debug { + "${ChatLogSummary.sid(sessionId ?: "pending")} kind=connection-retry app=${model.app.status} workspace=${model.workspace.status}" + } + // App retry policy is backend-owned and may escalate from lightweight refresh to restart. + if (model.app.status != KiloAppStatusDto.READY || model.app.status == KiloAppStatusDto.ERROR) { + app.retryAsync() + return + } + if (model.app.warnings.isNotEmpty()) { + app.retryAsync() + return + } + // Pure workspace failures stay scoped to workspace reload. + if (model.workspace.status == KiloWorkspaceStatusDto.ERROR) { + workspace.reload() + } + } + fun selectAgent(name: String) { LOG.debug { "${ChatLogSummary.sid(sessionId ?: "pending")} kind=config agent=$name" } model.agent = name diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index fde2db71c53..b890d0a66e1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -1,5 +1,7 @@ session.connection.connecting=Loading... session.connection.error.unknown=Unknown error +session.connection.retry=Retry +session.connection.warning.config=Configuration warnings session.status.considering=Considering next steps\u2026 session.status.thinking=Thinking\u2026 diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt new file mode 100644 index 00000000000..e98e705bec0 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt @@ -0,0 +1,153 @@ +package ai.kilocode.client.session.ui + +import ai.kilocode.client.session.update.SessionController +import ai.kilocode.client.session.update.SessionControllerTestBase +import ai.kilocode.rpc.dto.ConfigWarningDto +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.LoadErrorDto + +@Suppress("UnstableApiUsage") +class ConnectionPanelTest : SessionControllerTestBase() { + + private lateinit var panel: ConnectionPanel + private lateinit var controller: SessionController + + override fun setUp() { + super.setUp() + controller = controller("ses_test") + panel = ConnectionPanel(parent, controller) + flush() + } + + fun `test loading hides retry and details`() { + edt { + controller.model.app = KiloAppStateDto(KiloAppStatusDto.CONNECTING) + controller.model.workspace = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.PENDING) + panel.onEvent(ai.kilocode.client.session.update.SessionControllerEvent.AppChanged) + } + + assertTrue(panel.isVisible) + assertEquals("Loading...", panel.summaryText()) + assertEquals("", panel.detailsText()) + assertFalse(panel.toggleVisible()) + assertFalse(panel.detailsVisible()) + assertFalse(panel.retryVisible()) + } + + fun `test app error starts collapsed and expands details`() { + edt { + controller.model.app = KiloAppStateDto( + status = KiloAppStatusDto.ERROR, + error = "CLI startup failed", + errors = listOf( + LoadErrorDto(resource = "connection", detail = "stderr line"), + LoadErrorDto(resource = "config", detail = "HTTP 500: broken"), + ), + ) + panel.onEvent(ai.kilocode.client.session.update.SessionControllerEvent.AppChanged) + } + + assertTrue(panel.isVisible) + assertEquals("CLI startup failed", panel.summaryText()) + assertTrue(panel.toggleVisible()) + assertFalse(panel.toggleExpanded()) + assertFalse(panel.detailsVisible()) + assertEquals("stderr line\nconfig: HTTP 500: broken", panel.detailsText()) + assertTrue(panel.retryVisible()) + assertFalse(panel.retryFocusable()) + + edt { panel.clickSummary() } + + assertTrue(panel.toggleExpanded()) + assertTrue(panel.detailsVisible()) + + edt { panel.clickToggle() } + + assertFalse(panel.toggleExpanded()) + assertFalse(panel.detailsVisible()) + } + + fun `test workspace error shows retry without details`() { + edt { + controller.model.app = KiloAppStateDto(KiloAppStatusDto.READY) + controller.model.workspace = KiloWorkspaceStateDto( + status = KiloWorkspaceStatusDto.ERROR, + error = "Workspace failed", + ) + panel.onEvent(ai.kilocode.client.session.update.SessionControllerEvent.WorkspaceChanged) + } + + assertTrue(panel.isVisible) + assertEquals("Workspace failed", panel.summaryText()) + assertFalse(panel.toggleVisible()) + assertFalse(panel.detailsVisible()) + assertEquals("", panel.detailsText()) + assertTrue(panel.retryVisible()) + } + + fun `test retry click triggers app retry for app error`() { + edt { + controller.model.app = KiloAppStateDto( + status = KiloAppStatusDto.ERROR, + error = "CLI startup failed", + ) + panel.onEvent(ai.kilocode.client.session.update.SessionControllerEvent.AppChanged) + } + edt { panel.clickRetry() } + flush() + + assertEquals(1, appRpc.retries) + } + + fun `test ready warnings show collapsed banner with retry`() { + edt { + controller.model.app = KiloAppStateDto( + status = KiloAppStatusDto.READY, + warnings = listOf( + ConfigWarningDto( + path = ".kilo/kilo.json", + message = "Invalid JSON", + detail = "CloseBraceExpected at line 11, column 1", + ) + ), + ) + controller.model.workspace = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.READY) + panel.onEvent(ai.kilocode.client.session.update.SessionControllerEvent.AppChanged) + } + + assertTrue(panel.isVisible) + assertEquals("Configuration warnings", panel.summaryText()) + assertTrue(panel.toggleVisible()) + assertFalse(panel.toggleExpanded()) + assertFalse(panel.detailsVisible()) + assertTrue(panel.retryVisible()) + assertFalse(panel.retryFocusable()) + assertEquals( + ".kilo/kilo.json: Invalid JSON\nCloseBraceExpected at line 11, column 1", + panel.detailsText(), + ) + + edt { panel.clickSummary() } + + assertTrue(panel.toggleExpanded()) + assertTrue(panel.detailsVisible()) + } + + fun `test retry click triggers app retry for warnings`() { + edt { + controller.model.app = KiloAppStateDto( + status = KiloAppStatusDto.READY, + warnings = listOf(ConfigWarningDto(path = ".kilo/kilo.json", message = "Invalid JSON")), + ) + controller.model.workspace = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.READY) + panel.onEvent(ai.kilocode.client.session.update.SessionControllerEvent.AppChanged) + } + edt { panel.clickRetry() } + flush() + + assertEquals(1, appRpc.retries) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/AppWatchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/AppWatchingTest.kt index 521c5b2bb7a..b9b261be09f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/AppWatchingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/AppWatchingTest.kt @@ -1,5 +1,7 @@ package ai.kilocode.client.session.update +import ai.kilocode.rpc.dto.ConfigWarningDto +import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.KiloAppStatusDto @@ -23,4 +25,47 @@ class AppWatchingTest : SessionControllerTestBase() { show = false, ) } + + fun `test retry connection uses app retry when app is failed`() { + val m = controller() + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.ERROR, error = "boom") + + flush() + edt { m.retryConnection() } + flush() + + assertEquals(1, appRpc.retries) + assertEquals(0, projectRpc.reloads) + } + + fun `test retry connection reloads workspace when app ready and workspace failed`() { + val m = controller() + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + projectRpc.state.value = ai.kilocode.rpc.dto.KiloWorkspaceStateDto( + status = KiloWorkspaceStatusDto.ERROR, + error = "workspace fail", + ) + + flush() + edt { m.retryConnection() } + flush() + + assertEquals(0, appRpc.retries) + assertEquals(1, projectRpc.reloads) + } + + fun `test retry connection uses app retry when app has warnings`() { + val m = controller() + appRpc.state.value = KiloAppStateDto( + status = KiloAppStatusDto.READY, + warnings = listOf(ConfigWarningDto(path = ".kilo/kilo.json", message = "Invalid JSON")), + ) + + flush() + edt { m.retryConnection() } + flush() + + assertEquals(1, appRpc.retries) + assertEquals(0, projectRpc.reloads) + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt index 0919fcf3cbd..ea5b697745c 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt @@ -21,6 +21,8 @@ class FakeAppRpcApi : KiloAppRpcApi { var connected = false private set + var retries = 0 + private set override suspend fun connect() { assertNotEdt("connect") @@ -37,6 +39,11 @@ class FakeAppRpcApi : KiloAppRpcApi { return health } + override suspend fun retry() { + assertNotEdt("retry") + retries += 1 + } + override suspend fun restart() { assertNotEdt("restart") } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt index a85a1078058..c218008a5b1 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt @@ -18,6 +18,8 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi { var directory = "/test" val state = MutableStateFlow(KiloWorkspaceStateDto(KiloWorkspaceStatusDto.PENDING)) + var reloads = 0 + private set override suspend fun resolveProjectDirectory(hint: String): String { assertNotEdt("resolveProjectDirectory") @@ -31,5 +33,6 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi { override suspend fun reload(directory: String) { assertNotEdt("reload") + reloads += 1 } } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt index e0ea02ea1c2..01c614f7e09 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt @@ -31,6 +31,9 @@ interface KiloAppRpcApi : RemoteApi { /** One-shot health check against /global/health. */ suspend fun health(): HealthDto + /** Retry app connection or loading after a failure. */ + suspend fun retry() + /** Kill the CLI process and restart it. */ suspend fun restart() diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloAppStateDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloAppStateDto.kt index 0311799c2a5..3035f93d5f0 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloAppStateDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloAppStateDto.kt @@ -32,10 +32,18 @@ data class LoadErrorDto( val detail: String? = null, ) +@Serializable +data class ConfigWarningDto( + val path: String, + val message: String, + val detail: String? = null, +) + @Serializable data class KiloAppStateDto( val status: KiloAppStatusDto, val error: String? = null, val errors: List = emptyList(), val progress: LoadProgressDto? = null, + val warnings: List = emptyList(), ) From 09010ba9561a073e2eb411fcee8cd128162f71d9 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 27 Apr 2026 13:47:17 -0400 Subject: [PATCH 08/16] fix: improve JetBrains session panel layout --- .changeset/fuzzy-berries-press.md | 5 ++ .../ai/kilocode/client/session/SessionUi.kt | 42 +++------ .../client/session/ui/ConnectionPanel.kt | 19 +++- .../client/session/SessionUiLayoutTest.kt | 89 +++++++++++++------ .../client/session/ui/ConnectionPanelTest.kt | 20 +++++ 5 files changed, 117 insertions(+), 58 deletions(-) create mode 100644 .changeset/fuzzy-berries-press.md diff --git a/.changeset/fuzzy-berries-press.md b/.changeset/fuzzy-berries-press.md new file mode 100644 index 00000000000..0c48bad5166 --- /dev/null +++ b/.changeset/fuzzy-berries-press.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Show JetBrains session connection status inline above the prompt, cap expanded connection details, and keep empty sessions in the shared chat scroll area. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 5f948b60cda..f21ffa42e65 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -24,12 +24,10 @@ import ai.kilocode.log.KiloLog import com.intellij.ui.components.JBScrollPane import com.intellij.util.ui.JBUI import kotlinx.coroutines.CoroutineScope -import java.awt.Component import java.awt.BorderLayout import javax.swing.BoxLayout import javax.swing.BoxLayout.Y_AXIS import javax.swing.JPanel -import javax.swing.SwingUtilities /** * Top-level session UI composition root. @@ -72,7 +70,7 @@ class SessionUi( private lateinit var messageBody: SessionMessageListPanel - private lateinit var messageScroll: JBScrollPane + private lateinit var scroll: JBScrollPane private lateinit var question: QuestionPanel private lateinit var permission: PermissionPanel @@ -95,7 +93,7 @@ class SessionUi( emptyBody = EmptySessionPanel(this) messageBody = SessionMessageListPanel(controller.model, this) - messageScroll = JBScrollPane(messageBody).apply { + scroll = JBScrollPane(emptyBody).apply { border = JBUI.Borders.empty() verticalScrollBarPolicy = JBScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED horizontalScrollBarPolicy = JBScrollPane.HORIZONTAL_SCROLLBAR_NEVER @@ -110,28 +108,18 @@ class SessionUi( onAbort = { controller.abort() }, ) + sessionContent.add(scroll, BorderLayout.CENTER) root.content.add(sessionContent, BorderLayout.CENTER) - // Question and permission panels stay in normal flow so the prompt moves - // naturally when either dock appears. + // Dock panels stay in normal flow so each visible state takes layout space + // above the prompt. root.content.add(JPanel().apply { this.layout = BoxLayout(this, Y_AXIS) add(question) add(permission) + add(connection) add(prompt) }, BorderLayout.SOUTH) - // Keep connection status visually attached to the prompt without taking - // space in the south stack. - root.addOverlay(connection) { panel, child -> - val box = SwingUtilities.convertRectangle( - prompt.parent, - prompt.bounds, - panel - ) - val h = child.preferredSize.height - java.awt.Rectangle(box.x, maxOf(0, box.y - h), box.width, h) - } - add(root, BorderLayout.CENTER) } @@ -166,7 +154,7 @@ class SessionUi( } is SessionControllerEvent.ViewChanged -> - showBody(if (event.show) messageScroll else emptyBody) + showBody(if (event.show) messageBody else emptyBody) is SessionControllerEvent.AppChanged, is SessionControllerEvent.WorkspaceChanged -> @@ -230,7 +218,7 @@ class SessionUi( } private fun scrollToBottom() { - val bar = messageScroll.verticalScrollBar + val bar = scroll.verticalScrollBar bar.value = bar.maximum } @@ -239,15 +227,11 @@ class SessionUi( root.repaint() } - private fun showBody(panel: Component) { - if (sessionContent.getComponentCount() == 1 && sessionContent.getComponent( - 0 - ) === panel - ) return - sessionContent.removeAll() - sessionContent.add(panel, BorderLayout.CENTER) - sessionContent.revalidate() - sessionContent.repaint() + private fun showBody(panel: JPanel) { + if (scroll.viewport.view === panel) return + scroll.viewport.setView(panel) + scroll.revalidate() + scroll.repaint() } override fun dispose() {} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt index be9554f4359..794c593d730 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt @@ -30,6 +30,10 @@ class ConnectionPanel( private val controller: SessionController, ) : JPanel(BorderLayout()), SessionControllerListener, Disposable { + companion object { + private const val DETAILS_LINES = 10 + } + private val click = object : MouseAdapter() { override fun mouseClicked(e: MouseEvent) { flip() @@ -226,12 +230,16 @@ class ConnectionPanel( override fun getPreferredSize(): Dimension { val size = super.getPreferredSize() if (!scroll.isVisible) return size - val rows = details.getFontMetrics(details.font).height * 5 - val insets = scroll.insets.top + scroll.insets.bottom + scroll.horizontalScrollBar.preferredSize.height - val height = rows + insets + JBUI.scale(2) - return Dimension(size.width, maxOf(size.height, header.preferredSize.height + height)) + return Dimension(size.width, header.preferredSize.height + scrollHeight()) + } + + private fun scrollHeight(): Int { + val rows = details.text.lineSequence().count().coerceIn(1, DETAILS_LINES) + return details.getFontMetrics(details.font).height * rows + scrollChrome() } + private fun scrollChrome() = scroll.insets.top + scroll.insets.bottom + JBUI.scale(2) + internal fun summaryText() = label.text internal fun detailsText() = details.text @@ -260,6 +268,9 @@ class ConnectionPanel( internal fun retryFocusable() = retry.isFocusable internal fun clickRetry() = retry.doClick() + + internal fun maxExpandedHeight() = + header.preferredSize.height + details.getFontMetrics(details.font).height * DETAILS_LINES + scrollChrome() } private fun List.toErrorText(): String? { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt index ed3ebcdbb78..8c3e4fe858e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt @@ -11,11 +11,14 @@ import ai.kilocode.client.session.model.QuestionItem import ai.kilocode.client.session.model.QuestionOption import ai.kilocode.client.session.model.SessionState import ai.kilocode.client.session.ui.ConnectionPanel +import ai.kilocode.client.session.ui.EmptySessionPanel import ai.kilocode.client.session.ui.PermissionPanel import ai.kilocode.client.session.ui.PromptPanel import ai.kilocode.client.session.ui.QuestionPanel +import ai.kilocode.client.session.ui.SessionMessageListPanel import ai.kilocode.client.session.ui.SessionRootPanel import ai.kilocode.client.session.update.SessionController +import ai.kilocode.client.session.update.SessionControllerEvent import ai.kilocode.client.testing.FakeAppRpcApi import ai.kilocode.client.testing.FakeSessionRpcApi import ai.kilocode.client.testing.FakeWorkspaceRpcApi @@ -24,11 +27,11 @@ import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.JBScrollPane import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import javax.swing.JLayeredPane -import javax.swing.SwingUtilities @Suppress("UnstableApiUsage") class SessionUiLayoutTest : BasePlatformTestCase() { @@ -81,63 +84,99 @@ class SessionUiLayoutTest : BasePlatformTestCase() { assertEquals(JLayeredPane.PALETTE_LAYER, root.getLayer(root.overlay)) } - fun `test overlay panel matches prompt width and sits above prompt`() { + fun `test connection panel is docked between permission and prompt`() { + val root = find(ui) + val question = find(ui) + val permission = find(ui) + val connection = find(ui) val prompt = find(ui) + val stack = prompt.parent + + assertSame(root.content, stack.parent) + assertSame(stack, connection.parent) + assertEquals(0, root.overlay.componentCount) + assertEquals(listOf(question, permission, connection, prompt), stack.components.toList()) + } + + fun `test connection panel uses stack width and sits above prompt`() { val connection = find(ui) - val overlay = connection.parent + val prompt = find(ui) + val stack = prompt.parent + showConnection() layout() - val box = SwingUtilities.convertRectangle(prompt.parent, prompt.bounds, overlay) - val h = connection.preferredSize.height - - assertEquals(box.x, connection.x) - assertEquals(box.width, connection.width) - assertEquals(maxOf(0, box.y - h), connection.y) - assertEquals(h, connection.height) + assertTrue(connection.isVisible) + assertEquals(0, connection.x) + assertEquals(stack.width, connection.width) + assertEquals(prompt.width, connection.width) + assertTrue(connection.y + connection.height <= prompt.y) } - fun `test overlay follows prompt when question panel changes visibility`() { - val prompt = find(ui) + fun `test connection panel moves after visible question panel`() { val connection = find(ui) - val overlay = connection.parent val question = find(ui) + val prompt = find(ui) + showConnection() layout() assertFalse(question.isVisible) + val top = connection.y controller().model.setState(questionStateChanged()) layout() assertTrue(question.isVisible) - val box = SwingUtilities.convertRectangle(prompt.parent, prompt.bounds, overlay) - assertEquals(box.width, connection.width) - assertEquals(maxOf(0, box.y - connection.preferredSize.height), connection.y) - assertEquals(connection.preferredSize.height, connection.height) + assertTrue(question.y < connection.y) + assertTrue(top < connection.y) + assertTrue(connection.y + connection.height <= prompt.y) } - fun `test overlay follows prompt when permission panel changes visibility`() { - val prompt = find(ui) + fun `test connection panel moves after visible permission panel`() { val connection = find(ui) - val overlay = connection.parent val permission = find(ui) + val prompt = find(ui) + showConnection() layout() assertFalse(permission.isVisible) + val top = connection.y controller().model.setState(permissionStateChanged()) layout() assertTrue(permission.isVisible) - val box = SwingUtilities.convertRectangle(prompt.parent, prompt.bounds, overlay) - assertEquals(box.width, connection.width) - assertEquals(maxOf(0, box.y - connection.preferredSize.height), connection.y) - assertEquals(connection.preferredSize.height, connection.height) + assertTrue(permission.y < connection.y) + assertTrue(top < connection.y) + assertTrue(connection.y + connection.height <= prompt.y) + } + + fun `test empty and message bodies share the same scroll pane`() { + val scroll = find(ui) + val empty = find(ui) + + assertSame(empty, scroll.viewport.view) + + controller().prompt("hello") + layout() + + assertSame(scroll, find(ui).parent.parent) + assertSame(find(ui), scroll.viewport.view) } private fun layout() { ui.doLayout() - find(ui).doLayout() + val root = find(ui) + root.doLayout() + root.content.doLayout() + find(ui).parent.doLayout() + } + + private fun showConnection() { + val m = controller().model + m.app = KiloAppStateDto(KiloAppStatusDto.CONNECTING) + m.workspace = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.PENDING) + find(ui).onEvent(SessionControllerEvent.AppChanged) } private inline fun find(root: java.awt.Container): T { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt index e98e705bec0..39f8a0dbaf6 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt @@ -8,6 +8,7 @@ import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto import ai.kilocode.rpc.dto.LoadErrorDto +import java.awt.Dimension @Suppress("UnstableApiUsage") class ConnectionPanelTest : SessionControllerTestBase() { @@ -150,4 +151,23 @@ class ConnectionPanelTest : SessionControllerTestBase() { assertEquals(1, appRpc.retries) } + + fun `test expanded details height is capped at ten lines`() { + edt { + controller.model.app = KiloAppStateDto( + status = KiloAppStatusDto.ERROR, + error = "CLI startup failed", + errors = listOf(LoadErrorDto(resource = "connection", detail = lines(30))), + ) + panel.onEvent(ai.kilocode.client.session.update.SessionControllerEvent.AppChanged) + panel.size = Dimension(480, 1000) + } + + edt { panel.clickSummary() } + + assertTrue(panel.detailsVisible()) + assertTrue(panel.preferredSize.height <= panel.maxExpandedHeight()) + } + + private fun lines(count: Int) = (1..count).joinToString("\n") { "line $it" } } From 9927331c1cfbdb53348e2299e0da51a4edc09740 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 27 Apr 2026 17:25:55 -0400 Subject: [PATCH 09/16] feat: add JetBrains session history panel --- .changeset/chilly-dancers-build.md | 5 + .../backend/app/KiloBackendSessionManager.kt | 40 +++++ .../backend/rpc/KiloSessionRpcApiImpl.kt | 3 + .../app/KiloBackendSessionManagerTest.kt | 64 +++++++ .../kilocode/backend/testing/MockCliServer.kt | 8 + .../kilocode/client/KiloToolWindowFactory.kt | 29 ++-- .../client/actions/NewSessionAction.kt | 22 +++ .../kilocode/client/app/KiloSessionService.kt | 4 + .../kilocode/client/session/SessionManager.kt | 14 ++ .../client/session/SessionSidePanelManager.kt | 64 +++++++ .../ai/kilocode/client/session/SessionUi.kt | 15 +- .../client/session/SessionUiFactory.kt | 36 ++++ .../client/session/ui/EmptySessionPanel.kt | 138 ++++++++++++++- .../client/session/ui/SessionListPanel.kt | 155 +++++++++++++++++ .../session/update/SessionController.kt | 17 ++ .../src/main/resources/icons/plus.svg | 3 + .../src/main/resources/icons/plus_dark.svg | 3 + .../resources/messages/KiloBundle.properties | 11 ++ .../client/actions/NewSessionActionTest.kt | 55 ++++++ .../session/SessionSidePanelManagerTest.kt | 134 +++++++++++++++ .../client/session/SessionUiFactoryTest.kt | 109 ++++++++++++ .../client/session/SessionUiLayoutTest.kt | 57 ++++++- .../session/ui/EmptySessionPanelTest.kt | 158 ++++++++++++++++++ .../client/session/ui/SessionListPanelTest.kt | 89 ++++++++++ .../client/testing/FakeSessionRpcApi.kt | 15 ++ .../ai/kilocode/rpc/KiloSessionRpcApi.kt | 3 + 26 files changed, 1220 insertions(+), 31 deletions(-) create mode 100644 .changeset/chilly-dancers-build.md create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/NewSessionAction.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionManager.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUiFactory.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionListPanel.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/plus.svg create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/plus_dark.svg create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/NewSessionActionTest.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionListPanelTest.kt diff --git a/.changeset/chilly-dancers-build.md b/.changeset/chilly-dancers-build.md new file mode 100644 index 00000000000..3f8b568bc5d --- /dev/null +++ b/.changeset/chilly-dancers-build.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Add a JetBrains tool window action for starting new sessions and let recent sessions reopen in the side panel without duplicate views. diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt index ebe10cbf3bc..6094437a0f9 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt @@ -4,6 +4,7 @@ import ai.kilocode.backend.cli.KiloCliDataParser import ai.kilocode.log.ChatLogSummary import ai.kilocode.log.KiloLog import ai.kilocode.jetbrains.api.client.DefaultApi +import ai.kilocode.jetbrains.api.model.GlobalSession import ai.kilocode.jetbrains.api.model.SessionStatus import ai.kilocode.rpc.dto.SessionDto import ai.kilocode.rpc.dto.SessionListDto @@ -96,6 +97,21 @@ class KiloBackendSessionManager( return SessionListDto(mapped, relevant) } + fun recent(dir: String, limit: Int): SessionListDto { + seed(dir) + val raw = requireClient().experimentalSessionList( + directory = dir, + worktrees = true, + roots = true, + limit = limit.toDouble(), + archived = false, + ) + val mapped = raw.map(::dto) + val ids = mapped.map { it.id }.toSet() + val relevant = _statuses.value.filterKeys { it in ids } + return SessionListDto(mapped, relevant) + } + /** * Create a new session in the given directory. * @@ -182,8 +198,32 @@ class KiloBackendSessionManager( }, ) + private fun dto(s: GlobalSession) = SessionDto( + id = s.id, + projectID = s.projectID, + directory = s.directory, + parentID = s.parentID, + title = s.title, + version = s.version, + time = SessionTimeDto( + created = s.time.created, + updated = s.time.updated, + archived = s.time.archived, + ), + summary = s.summary?.let { + SessionSummaryDto( + additions = it.additions.toInt(), + deletions = it.deletions.toInt(), + files = it.files.toInt(), + ) + }, + ) + private fun statusDto(s: SessionStatus) = SessionStatusDto( type = s.type.value, message = s.message.ifBlank { null }, + attempt = s.attempt.toInt(), + next = s.next.toLong(), + requestID = s.requestID.ifBlank { null }, ) } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt index 89d8935c2b8..26b98a02a9f 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt @@ -51,6 +51,9 @@ class KiloSessionRpcApiImpl : KiloSessionRpcApi { override suspend fun list(directory: String): SessionListDto = workspaces.get(directory).sessions() + override suspend fun recent(directory: String, limit: Int): SessionListDto = + sessions.recent(directory, limit) + override suspend fun create(directory: String): SessionDto { LOG.info("create session: directory=$directory") return workspaces.get(directory).createSession() diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendSessionManagerTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendSessionManagerTest.kt index d98d978f5f3..867ad3ecbf7 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendSessionManagerTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendSessionManagerTest.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout +import java.net.URLDecoder import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals @@ -121,6 +122,69 @@ class KiloBackendSessionManagerTest { assertEquals("busy", result.statuses["ses_1"]?.type) } + @Test + fun `recent returns global sessions from experimental endpoint`() = runBlocking { + mock.recentSessions = """[ + {"id":"ses_1","slug":"s1","projectID":"prj","directory":"/repo","title":"Session 1","version":"1","time":{"created":1000,"updated":5000},"project":{"id":"prj","worktree":"/repo","name":"Repo"},"summary":{"additions":10,"deletions":2,"files":3}}, + {"id":"ses_2","slug":"s2","projectID":"prj","directory":"/repo-wt","title":"Session 2","version":"1","time":{"created":2000,"updated":4000},"project":{"id":"prj","worktree":"/repo","name":"Repo"},"parentID":"ses_parent"} + ]""" + val app = setup() + ready(app) + + val result = app.sessions.recent("/repo", 5) + + assertEquals(2, result.sessions.size) + assertEquals("ses_1", result.sessions[0].id) + assertEquals("Session 1", result.sessions[0].title) + assertEquals("/repo", result.sessions[0].directory) + assertEquals(10, result.sessions[0].summary?.additions) + assertEquals("ses_parent", result.sessions[1].parentID) + } + + @Test + fun `recent passes worktree filters and limit`() = runBlocking { + val app = setup() + ready(app) + + app.sessions.recent("/repo path", 5) + + val path = mock.lastExperimentalSessionPath ?: error("missing experimental session request") + assertTrue(path.startsWith("/experimental/session?")) + assertTrue(URLDecoder.decode(path, "UTF-8").contains("directory=/repo path"), path) + assertTrue(path.contains("worktrees=true"), path) + assertTrue(path.contains("roots=true"), path) + assertTrue(path.contains("limit=5.0"), path) + assertTrue(path.contains("archived=false"), path) + } + + @Test + fun `recent filters statuses to returned sessions`() = runBlocking { + mock.recentSessions = """[ + {"id":"ses_1","slug":"s1","projectID":"prj","directory":"/repo","title":"Session 1","version":"1","time":{"created":1,"updated":1},"project":{"id":"prj","worktree":"/repo","name":"Repo"}} + ]""" + mock.sessionStatuses = """{ + "ses_1": {"type":"busy","attempt":0,"message":"running","next":0,"requestID":""}, + "ses_other": {"type":"idle","attempt":0,"message":"","next":0,"requestID":""} + }""" + val app = setup() + ready(app) + + val result = app.sessions.recent("/repo", 5) + + assertEquals(setOf("ses_1"), result.statuses.keys) + assertEquals("busy", result.statuses["ses_1"]?.type) + assertEquals("running", result.statuses["ses_1"]?.message) + } + + @Test + fun `recent throws when not started`() = runBlocking { + val app = setup() + + assertFailsWith { + app.sessions.recent("/test", 5) + } + } + // ------ Session create ------ @Test diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt index 5bde260ff31..3c5010736f3 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt @@ -50,9 +50,11 @@ class MockCliServer : AutoCloseable { // Session REST responses @Volatile var sessions = "[]" + @Volatile var recentSessions = "[]" @Volatile var sessionCreate = """{"id":"ses_test","slug":"test","projectID":"prj_test","directory":"/test","title":"New Session","version":"1.0.0","time":{"created":1000,"updated":1000}}""" @Volatile var sessionStatuses = "{}" @Volatile var sessionsStatus = 200 + @Volatile var recentSessionsStatus = 200 @Volatile var sessionCreateStatus = 200 @Volatile var sessionGetStatus = 200 @Volatile var sessionDeleteStatus = 200 @@ -67,6 +69,8 @@ class MockCliServer : AutoCloseable { /** Return the number of requests received for [path] (bare, no query). */ fun requestCount(path: String): Int = counts[path]?.get() ?: 0 + @Volatile var lastExperimentalSessionPath: String? = null + /** Reset all request counters. */ fun resetCounts() { counts.clear() } @@ -200,6 +204,10 @@ class MockCliServer : AutoCloseable { bare == "/agent" -> respond(output, agentsStatus, agents) bare == "/command" -> respond(output, commandsStatus, commands) bare == "/skill" -> respond(output, skillsStatus, skills) + bare == "/experimental/session" -> { + lastExperimentalSessionPath = path + respond(output, recentSessionsStatus, recentSessions) + } bare == "/session/status" -> respond(output, sessionStatusesStatus, sessionStatuses) bare == "/session" && method == "GET" -> respond(output, sessionsStatus, sessions) bare == "/session" && method == "POST" -> respond(output, sessionCreateStatus, sessionCreate) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt index e44b34367dc..faa8477da37 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt @@ -1,13 +1,12 @@ package ai.kilocode.client -import ai.kilocode.client.app.KiloAppService -import ai.kilocode.client.app.KiloSessionService -import ai.kilocode.client.session.SessionUi +import ai.kilocode.client.actions.NewSessionAction import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.app.Workspace +import ai.kilocode.client.session.SessionSidePanelManager +import ai.kilocode.log.KiloLog import com.intellij.openapi.actionSystem.ActionManager import com.intellij.openapi.components.service -import ai.kilocode.log.KiloLog import com.intellij.openapi.project.DumbAware import com.intellij.openapi.project.Project import com.intellij.openapi.wm.ToolWindow @@ -20,7 +19,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext /** - * Creates the Kilo Code tool window with a single [SessionUi]. + * Creates the Kilo Code tool window and delegates session content management. * * Resolves the project directory through the backend (handles split-mode * where `project.basePath` is a synthetic frontend path) before creating @@ -36,8 +35,6 @@ class KiloToolWindowFactory : ToolWindowFactory, DumbAware { override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { try { val workspaces = service() - val sessions = project.service() - val app = service() val cs = CoroutineScope(SupervisorJob()) val hint = project.basePath ?: "" @@ -45,7 +42,7 @@ class KiloToolWindowFactory : ToolWindowFactory, DumbAware { val dir = workspaces.resolveProjectDirectory(hint) val workspace = workspaces.workspace(dir) withContext(Dispatchers.Main) { - setup(project, toolWindow, workspace, sessions, app, cs) + setup(project, toolWindow, workspace) } } } catch (e: Exception) { @@ -57,19 +54,17 @@ class KiloToolWindowFactory : ToolWindowFactory, DumbAware { project: Project, toolWindow: ToolWindow, workspace: Workspace, - sessions: KiloSessionService, - app: KiloAppService, - cs: CoroutineScope, ) { try { - val ui = SessionUi(project, workspace, sessions, app, cs) - val content = ContentFactory.getInstance() - .createContent(ui, "", false) - content.setDisposer(ui) + val manager = SessionSidePanelManager(project, workspace) + val content = ContentFactory.getInstance().createContent(manager.component, "", false) + content.setDisposer(manager) toolWindow.contentManager.addContent(content) + toolWindow.contentManager.setSelectedContent(content) + manager.newSession() - ActionManager.getInstance().getAction("Kilo.Settings")?.let { - toolWindow.setTitleActions(listOf(it)) + ActionManager.getInstance().getAction("Kilo.Settings")?.let { settings -> + toolWindow.setTitleActions(listOf(NewSessionAction(), settings)) } } catch (e: Exception) { LOG.error("Failed to set up Kilo tool window content", e) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/NewSessionAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/NewSessionAction.kt new file mode 100644 index 00000000000..b6d958e6e88 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/NewSessionAction.kt @@ -0,0 +1,22 @@ +package ai.kilocode.client.actions + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.SessionManager +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.util.IconLoader + +class NewSessionAction : AnAction( + KiloBundle.message("action.Kilo.NewSession.text"), + KiloBundle.message("action.Kilo.NewSession.description"), + IconLoader.getIcon("/icons/plus.svg", NewSessionAction::class.java), +), DumbAware { + override fun actionPerformed(e: AnActionEvent) { + e.getData(SessionManager.KEY)?.newSession() + } + + override fun update(e: AnActionEvent) { + e.presentation.isEnabled = e.getData(SessionManager.KEY) != null + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt index 5905fd0fa52..1b36aeefb02 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt @@ -84,6 +84,10 @@ class KiloSessionService internal constructor( } } + /** Load recent sessions for the current worktree family. */ + suspend fun recent(dir: String, limit: Int): List = + call { recent(dir, limit) }.sessions + /** Create a new session. Caller awaits the result. */ suspend fun create(dir: String): SessionDto { LOG.info("create: dir=$dir") 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 new file mode 100644 index 00000000000..a65cde6df18 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionManager.kt @@ -0,0 +1,14 @@ +package ai.kilocode.client.session + +import ai.kilocode.rpc.dto.SessionDto +import com.intellij.openapi.actionSystem.DataKey + +interface SessionManager { + companion object { + val KEY = DataKey.create("ai.kilocode.client.session.SessionManager") + } + + fun newSession() + + fun openSession(session: SessionDto) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt new file mode 100644 index 00000000000..e1c53a88d75 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt @@ -0,0 +1,64 @@ +package ai.kilocode.client.session + +import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.app.Workspace +import ai.kilocode.rpc.dto.SessionDto +import com.intellij.openapi.Disposable +import com.intellij.openapi.actionSystem.DataProvider +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import java.awt.BorderLayout +import javax.swing.JPanel + +class SessionSidePanelManager( + private val project: Project, + private val root: Workspace, + private val create: (Project, Workspace, SessionManager, String?) -> SessionUi = { project, workspace, manager, id -> + service().create(project, workspace, manager, id) + }, + private val resolve: (String) -> Workspace = { dir -> service().workspace(dir) }, +) : SessionManager, Disposable { + val component: JPanel = object : JPanel(BorderLayout()), DataProvider { + override fun getData(dataId: String): Any? { + if (SessionManager.KEY.`is`(dataId)) return this@SessionSidePanelManager + return null + } + } + + private val opened = mutableMapOf() + private val all = mutableSetOf() + private var current: SessionUi? = null + + override fun newSession() { + show(create(project, root, this, null)) + } + + override fun openSession(session: SessionDto) { + val ui = opened.getOrPut(session.id) { + create(project, resolve(session.directory), this, session.id).also { + all.add(it) + } + } + show(ui) + } + + private fun show(ui: SessionUi) { + all.add(ui) + if (current === ui) return + component.removeAll() + current = ui + component.add(ui, BorderLayout.CENTER) + component.revalidate() + component.repaint() + } + + override fun dispose() { + val items = all.toList() + opened.clear() + all.clear() + current = null + component.removeAll() + items.forEach { Disposer.dispose(it) } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index f21ffa42e65..94dc1e51000 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -16,6 +16,7 @@ import ai.kilocode.client.session.ui.SessionMessageListPanel import ai.kilocode.client.session.update.EVENT_FLUSH_MS import ai.kilocode.client.session.update.SessionController import ai.kilocode.client.session.update.SessionControllerEvent +import ai.kilocode.rpc.dto.SessionDto import com.intellij.openapi.Disposable import com.intellij.openapi.project.Project import com.intellij.openapi.util.registry.Registry @@ -41,6 +42,8 @@ class SessionUi( sessions: KiloSessionService, app: KiloAppService, cs: CoroutineScope, + id: String? = null, + private val onOpenSession: (SessionDto) -> Unit = {}, ) : JPanel(BorderLayout()), Disposable { companion object { @@ -48,7 +51,6 @@ class SessionUi( } private val project = project - private val flushMs = Registry.intValue("kilo.session.flushMs", EVENT_FLUSH_MS.toInt()) .takeIf { it > 0 } @@ -56,7 +58,7 @@ class SessionUi( ?: EVENT_FLUSH_MS private val controller = SessionController( - this, null, sessions, workspace, app, cs, this, + this, id, sessions, workspace, app, cs, this, flushMs = flushMs, condense = Registry.`is`("kilo.session.condense", true), ) @@ -78,7 +80,6 @@ class SessionUi( private lateinit var prompt: PromptPanel - init { buildUi() bindUi() @@ -90,7 +91,7 @@ class SessionUi( sessionContent = JPanel(BorderLayout()) - emptyBody = EmptySessionPanel(this) + emptyBody = EmptySessionPanel(this, controller, onOpenSession) messageBody = SessionMessageListPanel(controller.model, this) scroll = JBScrollPane(emptyBody).apply { @@ -153,12 +154,14 @@ class SessionUi( prompt.setReady(m.isReady()) } - is SessionControllerEvent.ViewChanged -> + is SessionControllerEvent.ViewChanged -> { showBody(if (event.show) messageBody else emptyBody) + } is SessionControllerEvent.AppChanged, - is SessionControllerEvent.WorkspaceChanged -> + is SessionControllerEvent.WorkspaceChanged -> { prompt.setReady(controller.model.isReady()) + } } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUiFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUiFactory.kt new file mode 100644 index 00000000000..ef5151d1874 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUiFactory.kt @@ -0,0 +1,36 @@ +package ai.kilocode.client.session + +import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloSessionService +import ai.kilocode.client.app.Workspace +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob + +@Service(Service.Level.APP) +class SessionUiFactory( + private val cs: CoroutineScope, +) { + fun create( + project: Project, + workspace: Workspace, + manager: SessionManager, + id: String? = null, + ): SessionUi = SessionUi( + project = project, + workspace = workspace, + sessions = project.service(), + app = service(), + cs = scope(), + id = id, + onOpenSession = manager::openSession, + ) + + private fun scope(): CoroutineScope { + val parent = cs.coroutineContext[Job] + return CoroutineScope(cs.coroutineContext + SupervisorJob(parent)) + } +} 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 8aa2c1208ee..1cfe45aa5c6 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,23 +1,49 @@ package ai.kilocode.client.session.ui +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.update.SessionController +import ai.kilocode.client.session.update.SessionControllerEvent +import ai.kilocode.client.session.update.SessionControllerListener +import ai.kilocode.client.ui.md.MdView +import ai.kilocode.rpc.dto.SessionDto import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer import com.intellij.openapi.util.IconLoader import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.Centerizer import com.intellij.util.ui.JBUI -import java.awt.GridBagConstraints -import java.awt.GridBagLayout +import com.intellij.util.ui.UIUtil +import java.awt.BorderLayout +import java.awt.Dimension +import javax.swing.Box +import javax.swing.BoxLayout import javax.swing.JPanel +import javax.swing.border.EmptyBorder /** * Centered empty-session panel. */ class EmptySessionPanel( parent: Disposable, -) : JPanel(GridBagLayout()), Disposable { + private val controller: SessionController, + open: (SessionDto) -> Unit = {}, +) : JPanel(BorderLayout()), SessionControllerListener, Disposable { + + companion object { + internal const val LIMIT = 5 + internal const val MAX_WIDTH = 350 + } init { Disposer.register(parent, this) + controller.addListener(this, this) + } + + private val list = SessionListPanel(LIMIT, open) + private val md = MdView.html().apply { + opaque = false + foreground = UIUtil.getContextHelpForeground() + set(KiloBundle.message("session.empty.welcome")) } private val logo = JBLabel( @@ -26,15 +52,113 @@ class EmptySessionPanel( alignmentX = CENTER_ALIGNMENT } + private val intro = JPanel(BorderLayout()).apply { + isOpaque = false + alignmentX = CENTER_ALIGNMENT + add(md.component, BorderLayout.CENTER) + border = JBUI.Borders.empty(0, 12, 0, 12) + } + + private val recent = JPanel(BorderLayout()).apply { + isOpaque = false + alignmentX = CENTER_ALIGNMENT + isVisible = false + add(JBLabel(KiloBundle.message("session.empty.recent")).apply { + foreground = UIUtil.getContextHelpForeground() + font = font.deriveFont(font.size2D - 1f) + border = JBUI.Borders.emptyLeft(8) + }, BorderLayout.NORTH) + add(list, BorderLayout.CENTER) + } + + private val stack = JPanel().apply { + isOpaque = false + layout = BoxLayout(this, BoxLayout.Y_AXIS) + add(logo) + add(Box.createVerticalStrut(JBUI.scale(14))) + add(intro) + add(Box.createVerticalStrut(JBUI.scale(28))) + add(recent) + } + + private val content = object : JPanel(BorderLayout()) { + override fun getPreferredSize(): Dimension { + val size = super.getPreferredSize() + return Dimension(JBUI.scale(MAX_WIDTH), size.height) + } + }.apply { + isOpaque = false + add(stack, BorderLayout.NORTH) + } + + private var loading = false + init { isOpaque = false + border = JBUI.Borders.empty(12) + add(Centerizer(content, Centerizer.TYPE.BOTH), BorderLayout.CENTER) + refresh() + } + + override fun addNotify() { + super.addNotify() + refresh(force = true) + } + + override fun onEvent(event: SessionControllerEvent) { + when (event) { + is SessionControllerEvent.AppChanged, + is SessionControllerEvent.WorkspaceChanged, + is SessionControllerEvent.WorkspaceReady -> { + if (controller.ready) refresh() + } + + is SessionControllerEvent.ViewChanged -> { + if (!event.show) refresh(force = true) + } + } + } + + fun refresh(force: Boolean = false) { + if (!force && loading) return + loading = true + controller.recent( + limit = LIMIT, + onResult = { + loading = false + setSessions(it) + }, + onError = { + loading = false + }, + ) + } - add(logo, GridBagConstraints().apply { - anchor = GridBagConstraints.CENTER - insets = JBUI.insets(12) - }) + internal fun setSessions(sessions: List) { + list.setSessions(sessions) + recent.isVisible = list.count() > 0 + revalidate() + repaint() } + internal fun recentCount() = list.count() + + internal fun selectRecent(index: Int) { + list.select(index) + } + + internal fun selectedRecent() = list.selected() + + internal fun clickRecent(index: Int) { + list.click(index) + } + + internal fun recentVisible() = recent.isVisible + + internal fun explanationMarkdown() = md.markdown() + + internal fun contentPreferredSize() = content.preferredSize + override fun dispose() { // no-op } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionListPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionListPanel.kt new file mode 100644 index 00000000000..d24aab10911 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionListPanel.kt @@ -0,0 +1,155 @@ +package ai.kilocode.client.session.ui + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.rpc.dto.SessionDto +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBList +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import java.awt.BorderLayout +import java.awt.Component +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import java.awt.event.MouseMotionAdapter +import javax.swing.DefaultListModel +import javax.swing.JList +import javax.swing.JPanel +import javax.swing.ListCellRenderer +import javax.swing.ListSelectionModel +import kotlin.math.abs + +class SessionListPanel( + private val limit: Int, + private val open: (SessionDto) -> Unit, +) : JPanel(BorderLayout()) { + companion object { + private const val MINUTE = 60_000L + private const val HOUR = 60 * MINUTE + private const val DAY = 24 * HOUR + } + + private val model = DefaultListModel() + private var hover = -1 + + private val list = JBList(model).apply { + isOpaque = false + selectionMode = ListSelectionModel.SINGLE_SELECTION + visibleRowCount = limit + cellRenderer = SessionRenderer() + emptyText.clear() + addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + val index = row(e) + if (index < 0) return + selectedIndex = index + open(model.getElementAt(index)) + } + + override fun mouseExited(e: MouseEvent) { + hover = -1 + repaint() + } + }) + addMouseMotionListener(object : MouseMotionAdapter() { + override fun mouseMoved(e: MouseEvent) { + val index = row(e) + if (hover == index) return + hover = index + repaint() + } + }) + } + + init { + isOpaque = false + add(list, BorderLayout.CENTER) + } + + fun setSessions(sessions: List) { + model.clear() + sessions.take(limit).forEach(model::addElement) + revalidate() + repaint() + } + + fun count() = model.size() + + internal fun select(index: Int) { + list.selectedIndex = index + } + + internal fun selected() = list.selectedIndex + + internal fun click(index: Int) { + list.selectedIndex = index + open(model.getElementAt(index)) + } + + internal fun text(session: SessionDto, now: Long = System.currentTimeMillis()) = time(session, now) + + internal fun normalize(value: Double): Long { + val raw = value.toLong() + if (abs(raw) < 10_000_000_000L) return raw * 1000 + return raw + } + + internal fun rendererComponent( + session: SessionDto, + selected: Boolean = false, + hover: Boolean = false, + ): Component { + val old = this.hover + this.hover = if (hover) 0 else -1 + return list.cellRenderer.getListCellRendererComponent(list, session, 0, selected, false).also { + this.hover = old + } + } + + private fun row(e: MouseEvent): Int { + val index = list.locationToIndex(e.point) + if (index < 0) return -1 + val box = list.getCellBounds(index, index) ?: return -1 + if (!box.contains(e.point)) return -1 + return index + } + + private inner class SessionRenderer : JPanel(BorderLayout()), ListCellRenderer { + private val title = JBLabel() + private val time = JBLabel() + + init { + border = JBUI.Borders.empty(8, 8, 8, 8) + add(title, BorderLayout.CENTER) + add(time, BorderLayout.EAST) + } + + override fun getListCellRendererComponent( + list: JList, + value: SessionDto?, + index: Int, + selected: Boolean, + focus: Boolean, + ): Component { + val active = selected || hover == index + isOpaque = active + background = if (active) list.selectionBackground else list.background + title.foreground = if (active) list.selectionForeground else UIUtil.getLabelForeground() + time.foreground = if (active) list.selectionForeground else UIUtil.getContextHelpForeground() + title.text = value?.let(::title) ?: "" + time.text = value?.let { time(it) } ?: "" + return this + } + } + + private fun title(session: SessionDto) = + session.title.takeIf { it.isNotBlank() } ?: KiloBundle.message("session.tab.untitled") + + private fun time(session: SessionDto, now: Long = System.currentTimeMillis()): String { + val ms = normalize(session.time.updated) + val diff = (now - ms).coerceAtLeast(0) + if (diff < MINUTE) return KiloBundle.message("session.empty.time.moments") + if (diff < HOUR) return KiloBundle.message("session.empty.time.minutes", (diff / MINUTE).coerceAtLeast(1)) + if (diff < DAY) return KiloBundle.message("session.empty.time.hours", (diff / HOUR).coerceAtLeast(1)) + return KiloBundle.message("session.empty.time.days", (diff / DAY).coerceAtLeast(1)) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt index 09a280cad03..76341d30749 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt @@ -25,6 +25,7 @@ import ai.kilocode.rpc.dto.PermissionReplyDto import ai.kilocode.rpc.dto.PermissionRequestDto import ai.kilocode.rpc.dto.QuestionReplyDto import ai.kilocode.rpc.dto.QuestionRequestDto +import ai.kilocode.rpc.dto.SessionDto import ai.kilocode.rpc.dto.SessionStatusDto import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager @@ -89,6 +90,22 @@ class SessionController( val ready: Boolean get() = model.isReady() + fun recent( + limit: Int, + onResult: (List) -> Unit, + onError: () -> Unit = {}, + ) { + cs.launch { + try { + val items = sessions.recent(directory, limit) + edt { onResult(items) } + } catch (e: Exception) { + LOG.warn("kind=session-recent dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) + edt { onError() } + } + } + } + fun addListener(parent: Disposable, listener: SessionControllerListener) { listeners.add(listener) Disposer.register(parent) { listeners.remove(listener) } diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/plus.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/plus.svg new file mode 100644 index 00000000000..84f0a44ca51 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/plus.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/plus_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/plus_dark.svg new file mode 100644 index 00000000000..84f0a44ca51 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/plus_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index b890d0a66e1..fc5b4f0c406 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -3,6 +3,15 @@ session.connection.error.unknown=Unknown error session.connection.retry=Retry session.connection.warning.config=Configuration warnings +session.empty.welcome=Kilo Code is an AI coding assistant. Ask it to build features, fix bugs, or explain your codebase. +session.empty.recent=RECENT +session.empty.time.moments=Moments ago +session.empty.time.minutes={0} min ago +session.empty.time.hours={0}h ago +session.empty.time.days={0}d ago +session.tab.new=New Session +session.tab.untitled=Untitled Session + session.status.considering=Considering next steps\u2026 session.status.thinking=Thinking\u2026 session.status.writing=Writing response\u2026 @@ -23,6 +32,8 @@ prompt.button.stop=Stop action.Kilo.Settings.text=Settings action.Kilo.Settings.description=Kilo Code settings +action.Kilo.NewSession.text=New Session +action.Kilo.NewSession.description=Start a new Kilo session action.Kilo.SettingsGroup.text=Settings action.Kilo.SettingsGroup.description=Kilo Code settings action.Kilo.Restart.text=Restart Kilo 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 new file mode 100644 index 00000000000..d9da97616d8 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/NewSessionActionTest.kt @@ -0,0 +1,55 @@ +package ai.kilocode.client.actions + +import ai.kilocode.client.session.SessionManager +import ai.kilocode.rpc.dto.SessionDto +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.actionSystem.Presentation +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +@Suppress("UnstableApiUsage") +class NewSessionActionTest : BasePlatformTestCase() { + fun `test action invokes manager from data context and has presentation`() { + val manager = FakeManager() + val action = NewSessionAction() + val event = event(manager) + + action.update(event) + action.actionPerformed(event) + + assertEquals(1, manager.created) + assertTrue(event.presentation.isEnabled) + assertEquals("New Session", action.templatePresentation.text) + assertEquals("Start a new Kilo session", action.templatePresentation.description) + assertNotNull(action.templatePresentation.icon) + } + + fun `test update disables action without manager`() { + val action = NewSessionAction() + val presentation = Presentation().apply { copyFrom(action.templatePresentation) } + + action.update(AnActionEvent.createFromDataContext("", presentation) { null }) + + assertFalse(presentation.isEnabled) + } + + private fun event(manager: SessionManager): AnActionEvent { + val presentation = Presentation().apply { + copyFrom(NewSessionAction().templatePresentation) + } + val context = DataContext { id -> + if (SessionManager.KEY.`is`(id)) manager else null + } + return AnActionEvent.createFromDataContext("", presentation, context) + } + + private class FakeManager : SessionManager { + var created = 0 + override fun newSession() { + created++ + } + + override fun openSession(session: SessionDto) { + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt new file mode 100644 index 00000000000..fc1d4ad8862 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt @@ -0,0 +1,134 @@ +package ai.kilocode.client.session + +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.testing.FakeAppRpcApi +import ai.kilocode.client.testing.FakeSessionRpcApi +import ai.kilocode.client.testing.FakeWorkspaceRpcApi +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.SessionDto +import ai.kilocode.rpc.dto.SessionTimeDto +import com.intellij.openapi.actionSystem.DataProvider +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import javax.swing.JPanel + +@Suppress("UnstableApiUsage") +class SessionSidePanelManagerTest : BasePlatformTestCase() { + private lateinit var scope: CoroutineScope + private lateinit var workspaces: KiloWorkspaceService + private lateinit var workspace: Workspace + private lateinit var sessions: KiloSessionService + private lateinit var app: KiloAppService + private val managers = mutableListOf() + private val created = mutableListOf>() + + override fun setUp() { + super.setUp() + scope = CoroutineScope(SupervisorJob()) + sessions = KiloSessionService(project, scope, FakeSessionRpcApi()) + app = KiloAppService(scope, FakeAppRpcApi().also { + it.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + }) + workspaces = KiloWorkspaceService(scope, FakeWorkspaceRpcApi().also { + it.state.value = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.READY) + }) + workspace = workspaces.workspace("/test") + } + + override fun tearDown() { + try { + managers.forEach { Disposer.dispose(it) } + scope.cancel() + } finally { + super.tearDown() + } + } + + fun `test component provides session manager`() { + val manager = manager() + val provider = manager.component as DataProvider + + assertSame(manager, provider.getData(SessionManager.KEY.name)) + } + + fun `test new session replaces active component`() { + val manager = manager() + + manager.newSession() + val first = active(manager) + manager.newSession() + val second = active(manager) + + assertNotSame(first, second) + assertEquals(listOf("/test" to null, "/test" to null), created) + } + + fun `test opening same existing session reuses component`() { + val manager = manager() + val session = session("ses_1") + + manager.openSession(session) + val first = active(manager) + manager.newSession() + manager.openSession(session) + val second = active(manager) + + assertSame(first, second) + assertEquals(listOf("/test" to "ses_1", "/test" to null), created) + } + + fun `test open session resolves historical workspace`() { + val manager = manager() + + manager.openSession(session("ses_1", "/repo")) + + assertEquals(listOf("/repo" to "ses_1"), created) + } + + fun `test dispose removes active component`() { + val manager = manager() + + manager.newSession() + Disposer.dispose(manager) + managers.remove(manager) + + assertEquals(0, manager.component.componentCount) + } + + private fun manager(): SessionSidePanelManager { + val manager = SessionSidePanelManager( + project = project, + root = workspace, + create = { project, workspace, owner, id -> + created.add(workspace.directory to id) + SessionUi(project, workspace, sessions, app, scope, id = id, onOpenSession = owner::openSession) + }, + resolve = { workspaces.workspace(it) }, + ) + managers.add(manager) + return manager + } + + private fun active(manager: SessionSidePanelManager) = manager.component.getComponent(0) as JPanel + + private fun session(id: String) = session(id, "/test") + + private fun session(id: String, dir: String) = SessionDto( + id = id, + projectID = "prj", + directory = dir, + title = "Session $id", + version = "1", + time = SessionTimeDto(created = 1.0, updated = 2.0), + ) + +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt new file mode 100644 index 00000000000..183f44df951 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt @@ -0,0 +1,109 @@ +package ai.kilocode.client.session + +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.testing.FakeAppRpcApi +import ai.kilocode.client.testing.FakeSessionRpcApi +import ai.kilocode.client.testing.FakeWorkspaceRpcApi +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.SessionDto +import ai.kilocode.rpc.dto.SessionTimeDto +import com.intellij.openapi.components.service +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel + +@Suppress("UnstableApiUsage") +class SessionUiFactoryTest : BasePlatformTestCase() { + private lateinit var scope: CoroutineScope + private lateinit var workspace: Workspace + private lateinit var sessions: KiloSessionService + private lateinit var app: KiloAppService + + override fun setUp() { + super.setUp() + scope = CoroutineScope(SupervisorJob()) + sessions = KiloSessionService(project, scope, FakeSessionRpcApi()) + app = KiloAppService(scope, FakeAppRpcApi().also { + it.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + }) + val workspaces = KiloWorkspaceService(scope, FakeWorkspaceRpcApi().also { + it.state.value = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.READY) + }) + workspace = workspaces.workspace("/test") + } + + override fun tearDown() { + try { + scope.cancel() + } finally { + super.tearDown() + } + } + + fun `test factory creates blank session ui`() { + val ui = direct().create(project, workspace, FakeManager()) + + assertNotNull(ui) + } + + fun `test factory wires open callback`() { + val manager = FakeManager() + val ui = SessionUi(project, workspace, sessions, app, scope, onOpenSession = manager::openSession) + val panel = find(ui) + + panel.onEvent(ai.kilocode.client.session.update.SessionControllerEvent.WorkspaceReady) + val rpc = session("ses_1") + panel.setSessions(listOf(rpc)) + panel.clickRecent(0) + + assertEquals(listOf("ses_1"), manager.opened) + } + + fun `test application service is available`() { + assertNotNull(service()) + } + + private fun direct() = SessionUiFactory(scope) + + private inline fun find(root: java.awt.Container): T { + return find(root, T::class.java) ?: error("missing ${T::class.java.simpleName}") + } + + private fun find(root: java.awt.Container, cls: Class): T? { + if (cls.isInstance(root)) return cls.cast(root) + for (child in root.components) { + if (cls.isInstance(child)) return cls.cast(child) + if (child is java.awt.Container) { + val item = find(child, cls) + if (item != null) return item + } + } + return null + } + + private fun session(id: String) = SessionDto( + id = id, + projectID = "prj", + directory = "/test", + title = "Session $id", + version = "1", + time = SessionTimeDto(created = 1.0, updated = 2.0), + ) + + private class FakeManager : SessionManager { + val opened = mutableListOf() + override fun newSession() { + } + + override fun openSession(session: SessionDto) { + opened.add(session.id) + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt index 8c3e4fe858e..cfeee3caa63 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt @@ -26,11 +26,18 @@ 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.MessageDto +import ai.kilocode.rpc.dto.MessageTimeDto +import ai.kilocode.rpc.dto.MessageWithPartsDto +import ai.kilocode.rpc.dto.SessionDto +import ai.kilocode.rpc.dto.SessionTimeDto import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.ui.components.JBScrollPane import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking import javax.swing.JLayeredPane @Suppress("UnstableApiUsage") @@ -40,6 +47,7 @@ class SessionUiLayoutTest : BasePlatformTestCase() { private lateinit var sessions: KiloSessionService private lateinit var app: KiloAppService private lateinit var workspaces: KiloWorkspaceService + private lateinit var rpc: FakeSessionRpcApi private lateinit var workspace: Workspace private lateinit var ui: SessionUi @@ -47,7 +55,7 @@ class SessionUiLayoutTest : BasePlatformTestCase() { super.setUp() scope = CoroutineScope(SupervisorJob()) - val rpc = FakeSessionRpcApi() + rpc = FakeSessionRpcApi() val appRpc = FakeAppRpcApi().also { it.state.value = KiloAppStateDto(KiloAppStatusDto.READY) } @@ -164,6 +172,30 @@ class SessionUiLayoutTest : BasePlatformTestCase() { assertSame(find(ui), scroll.viewport.view) } + fun `test clicking recent session calls opener`() { + val opened = mutableListOf() + rpc.recent.add(session("ses_1")) + ui = SessionUi(project, workspace, sessions, app, scope, onOpenSession = { opened.add(it.id) }).apply { + setSize(800, 600) + } + + settle() + find(ui).clickRecent(0) + + assertEquals(listOf("ses_1"), opened) + } + + fun `test existing session id loads history and shows message body`() { + rpc.history.add(MessageWithPartsDto(message("msg1"), emptyList())) + + ui = SessionUi(project, workspace, sessions, app, scope, id = "ses_test").apply { + setSize(800, 600) + } + settle() + + assertSame(find(ui), find(ui).viewport.view) + } + private fun layout() { ui.doLayout() val root = find(ui) @@ -172,6 +204,13 @@ class SessionUiLayoutTest : BasePlatformTestCase() { find(ui).parent.doLayout() } + private fun settle() = runBlocking { + repeat(5) { + delay(100) + com.intellij.util.ui.UIUtil.dispatchAllInvocationEvents() + } + } + private fun showConnection() { val m = controller().model m.app = KiloAppStateDto(KiloAppStatusDto.CONNECTING) @@ -226,4 +265,20 @@ class SessionUiLayoutTest : BasePlatformTestCase() { meta = PermissionMeta(raw = emptyMap()), ) ) + + private fun session(id: String) = SessionDto( + id = id, + projectID = "prj", + directory = "/test", + title = "Recent $id", + version = "1", + time = SessionTimeDto(created = 1.0, updated = 2.0), + ) + + private fun message(id: String) = MessageDto( + id = id, + sessionID = "ses_test", + role = "user", + time = MessageTimeDto(created = 0.0), + ) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt new file mode 100644 index 00000000000..2068d94fcc7 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt @@ -0,0 +1,158 @@ +package ai.kilocode.client.session.ui + +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.session.update.SessionController +import ai.kilocode.client.session.update.SessionControllerEvent +import ai.kilocode.client.testing.FakeAppRpcApi +import ai.kilocode.client.testing.FakeSessionRpcApi +import ai.kilocode.client.testing.FakeWorkspaceRpcApi +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.SessionDto +import ai.kilocode.rpc.dto.SessionTimeDto +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking + +@Suppress("UnstableApiUsage") +class EmptySessionPanelTest : BasePlatformTestCase() { + private lateinit var scope: CoroutineScope + private lateinit var rpc: FakeSessionRpcApi + private lateinit var app: KiloAppService + private lateinit var workspace: Workspace + private lateinit var controller: SessionController + + override fun setUp() { + super.setUp() + scope = CoroutineScope(SupervisorJob()) + rpc = FakeSessionRpcApi() + app = KiloAppService(scope, FakeAppRpcApi().also { + it.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + }) + val workspaces = KiloWorkspaceService(scope, FakeWorkspaceRpcApi().also { + it.state.value = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.READY) + }) + workspace = workspaces.workspace("/test") + controller = SessionController( + parent = testRootDisposable, + id = null, + sessions = KiloSessionService(project, scope, rpc), + workspace = workspace, + app = app, + cs = scope, + ) + } + + override fun tearDown() { + try { + scope.cancel() + } finally { + super.tearDown() + } + } + + fun `test recent section is hidden when empty`() { + val panel = panel() + + panel.setSessions(emptyList()) + + assertFalse(panel.recentVisible()) + assertEquals(0, panel.recentCount()) + } + + fun `test empty state has visible preferred height`() { + val panel = panel() + + assertTrue(panel.preferredSize.height > 0) + } + + fun `test content has fixed preferred width`() { + val panel = panel() + + assertEquals(com.intellij.util.ui.JBUI.scale(EmptySessionPanel.MAX_WIDTH), panel.contentPreferredSize().width) + assertTrue(panel.contentPreferredSize().height > 0) + } + + fun `test recent sessions are capped at five`() { + val panel = panel() + + panel.setSessions((1..7).map { session("ses_$it") }) + + assertTrue(panel.recentVisible()) + assertEquals(5, panel.recentCount()) + } + + fun `test explanation uses markdown view`() { + val panel = panel() + + assertEquals( + "Kilo Code is an AI coding assistant. Ask it to build features, fix bugs, or explain your codebase.", + panel.explanationMarkdown(), + ) + } + + fun `test panel loads recent sessions`() { + rpc.recent.add(session("ses_1")) + val panel = panel() + + settle() + + assertTrue(rpc.recentCalls.contains("/test" to 5)) + assertTrue(panel.recentVisible()) + assertEquals(1, panel.recentCount()) + } + + fun `test panel retries recent sessions when controller becomes ready`() { + rpc.recentFailures = 1 + rpc.recent.add(session("ses_1")) + val panel = panel() + + settle() + panel.onEvent(SessionControllerEvent.WorkspaceReady) + settle() + + assertTrue(rpc.recentCalls.size >= 2) + assertTrue(panel.recentVisible()) + assertEquals(1, panel.recentCount()) + } + + fun `test panel refreshes when shown after hide`() { + rpc.recent.add(session("ses_1")) + val panel = panel() + settle() + rpc.recent.clear() + rpc.recent.add(session("ses_2")) + + panel.onEvent(SessionControllerEvent.ViewChanged(false)) + settle() + + assertTrue(rpc.recentCalls.size >= 2) + assertEquals(1, panel.recentCount()) + } + + private fun panel(open: (SessionDto) -> Unit = {}) = EmptySessionPanel(testRootDisposable, controller, open) + + private fun settle() = runBlocking { + repeat(5) { + delay(100) + com.intellij.util.ui.UIUtil.dispatchAllInvocationEvents() + } + } + + private fun session(id: String) = SessionDto( + id = id, + projectID = "prj", + directory = "/repo/$id", + title = "Title $id", + version = "1", + time = SessionTimeDto(created = 1.0, updated = 2.0), + ) +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionListPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionListPanelTest.kt new file mode 100644 index 00000000000..cf7ee5e1a26 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionListPanelTest.kt @@ -0,0 +1,89 @@ +package ai.kilocode.client.session.ui + +import ai.kilocode.rpc.dto.SessionDto +import ai.kilocode.rpc.dto.SessionTimeDto +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.awt.BorderLayout +import javax.swing.JPanel + +@Suppress("UnstableApiUsage") +class SessionListPanelTest : BasePlatformTestCase() { + fun `test sessions are capped by constructor limit`() { + val panel = panel(limit = 5) + + panel.setSessions((1..7).map { session("ses_$it") }) + + assertEquals(5, panel.count()) + } + + fun `test selecting recent session does not invoke callback`() { + val clicked = mutableListOf() + val panel = panel { clicked.add(it.id) } + + panel.setSessions(listOf(session("ses_1"), session("ses_2"))) + panel.select(1) + + assertEquals(1, panel.selected()) + assertEquals(emptyList(), clicked) + } + + fun `test clicking recent session invokes callback`() { + val clicked = mutableListOf() + val panel = panel { clicked.add(it.id) } + + panel.setSessions(listOf(session("ses_1"), session("ses_2"))) + panel.click(1) + + assertEquals(listOf("ses_2"), clicked) + } + + fun `test renderer aligns title center and time east`() { + val cell = panel().rendererComponent(session("ses_1")) as JPanel + val layout = cell.layout as BorderLayout + + assertNotNull(layout.getLayoutComponent(BorderLayout.CENTER)) + assertNotNull(layout.getLayoutComponent(BorderLayout.EAST)) + } + + fun `test hover uses selection colors`() { + val panel = panel() + val session = session("ses_1") + val selected = panel.rendererComponent(session, selected = true) as JPanel + val hovered = panel.rendererComponent(session, hover = true) as JPanel + + assertTrue(selected.isOpaque) + assertTrue(hovered.isOpaque) + assertEquals(selected.background, hovered.background) + } + + fun `test timestamp normalization handles seconds and milliseconds`() { + val panel = panel() + + assertEquals(1_700_000_000_000L, panel.normalize(1_700_000_000.0)) + assertEquals(1_700_000_000_000L, panel.normalize(1_700_000_000_000.0)) + } + + fun `test timestamp renders coarse relative text`() { + val panel = panel() + val now = 1_700_000_000_000L + + assertEquals("Moments ago", panel.text(session("ses_1", now - 30_000), now)) + assertEquals("2 min ago", panel.text(session("ses_1", now - 120_000), now)) + assertEquals("3h ago", panel.text(session("ses_1", now - 10_800_000), now)) + assertEquals("4d ago", panel.text(session("ses_1", now - 345_600_000), now)) + } + + private fun panel( + limit: Int = 5, + open: (SessionDto) -> Unit = {}, + ) = SessionListPanel(limit, open) + + private fun session(id: String, updated: Long = 2_000L) = SessionDto( + id = id, + projectID = "prj", + directory = "/repo/$id", + title = "Title $id", + version = "1", + time = SessionTimeDto(created = 1.0, updated = updated.toDouble()), + ) +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt index de70b1424c9..90a39cde34c 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt @@ -42,6 +42,10 @@ class FakeSessionRpcApi : KiloSessionRpcApi { /** Message history returned by [messages]. */ val history = mutableListOf() + /** Recent sessions returned by [recent]. */ + val recent = mutableListOf() + var recentFailures = 0 + /** Push chat events here; tests collect from [events]. */ val events = MutableSharedFlow(extraBufferCapacity = 64, replay = 64) @@ -66,6 +70,7 @@ class FakeSessionRpcApi : KiloSessionRpcApi { val permissionRulesSaved = mutableListOf>() val questionReplies = mutableListOf>() val questionRejects = mutableListOf>() + val recentCalls = mutableListOf>() var creates = 0 private set @@ -82,6 +87,16 @@ class FakeSessionRpcApi : KiloSessionRpcApi { return SessionListDto(emptyList(), emptyMap()) } + override suspend fun recent(directory: String, limit: Int): SessionListDto { + assertNotEdt("recent") + recentCalls.add(directory to limit) + if (recentFailures > 0) { + recentFailures-- + throw IllegalStateException("recent unavailable") + } + return SessionListDto(recent.take(limit), emptyMap()) + } + override suspend fun get(id: String, directory: String): SessionDto { assertNotEdt("get") return session diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt index bffa2bf5be7..330bfb8c113 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt @@ -37,6 +37,9 @@ interface KiloSessionRpcApi : RemoteApi { /** List root sessions for a directory. */ suspend fun list(directory: String): SessionListDto + /** List recent root sessions for the current worktree family. */ + suspend fun recent(directory: String, limit: Int): SessionListDto + /** Create a new session in the given directory. */ suspend fun create(directory: String): SessionDto From 10caa9cf633534a9d1d1b3e32f08f564175fad8e Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 28 Apr 2026 10:06:02 -0400 Subject: [PATCH 10/16] refactor: move JetBrains session view state into controller --- .../ai/kilocode/client/session/SessionUi.kt | 37 ++- .../client/session/SessionUiFactory.kt | 2 +- .../client/session/ui/EmptySessionPanel.kt | 267 +++++++++++------- .../client/session/ui/SessionListPanel.kt | 155 ---------- .../session/update/SessionController.kt | 57 +++- .../session/update/SessionControllerEvent.kt | 16 +- .../session/SessionSidePanelManagerTest.kt | 2 +- .../client/session/SessionUiFactoryTest.kt | 40 +-- .../client/session/SessionUiLayoutTest.kt | 4 +- .../session/ui/EmptySessionPanelTest.kt | 102 ++++--- .../client/session/ui/SessionListPanelTest.kt | 89 ------ .../session/update/HistoryLoadingTest.kt | 8 + .../session/update/ListenerLifecycleTest.kt | 4 +- .../session/update/SessionCreationTest.kt | 2 +- .../session/update/ViewSwitchingTest.kt | 64 ++++- .../session/update/WorkspaceWatchingTest.kt | 2 + 16 files changed, 415 insertions(+), 436 deletions(-) delete mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionListPanel.kt delete mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionListPanelTest.kt diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 94dc1e51000..6dbca2ca4f0 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.session import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloSessionService import ai.kilocode.client.app.Workspace +import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.client.session.model.SessionState import ai.kilocode.client.session.ui.ConnectionPanel @@ -17,12 +18,14 @@ import ai.kilocode.client.session.update.EVENT_FLUSH_MS import ai.kilocode.client.session.update.SessionController import ai.kilocode.client.session.update.SessionControllerEvent import ai.kilocode.rpc.dto.SessionDto +import ai.kilocode.log.ChatLogSummary +import ai.kilocode.log.KiloLog import com.intellij.openapi.Disposable import com.intellij.openapi.project.Project import com.intellij.openapi.util.registry.Registry -import ai.kilocode.log.ChatLogSummary -import ai.kilocode.log.KiloLog +import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBScrollPane +import com.intellij.util.ui.Centerizer import com.intellij.util.ui.JBUI import kotlinx.coroutines.CoroutineScope import java.awt.BorderLayout @@ -43,7 +46,7 @@ class SessionUi( app: KiloAppService, cs: CoroutineScope, id: String? = null, - private val onOpenSession: (SessionDto) -> Unit = {}, + open: (SessionDto) -> Unit = {}, ) : JPanel(BorderLayout()), Disposable { companion object { @@ -61,6 +64,7 @@ class SessionUi( this, id, sessions, workspace, app, cs, this, flushMs = flushMs, condense = Registry.`is`("kilo.session.condense", true), + open = open, ) @@ -68,7 +72,7 @@ class SessionUi( private lateinit var sessionContent: JPanel - private lateinit var emptyBody: EmptySessionPanel + private lateinit var progressBody: JPanel private lateinit var messageBody: SessionMessageListPanel @@ -83,7 +87,7 @@ class SessionUi( init { buildUi() bindUi() - showBody(emptyBody) + showBody(progressBody) } private fun buildUi() { @@ -91,10 +95,16 @@ class SessionUi( sessionContent = JPanel(BorderLayout()) - emptyBody = EmptySessionPanel(this, controller, onOpenSession) + progressBody = JPanel(BorderLayout()).apply { + isOpaque = false + add(Centerizer( + JBLabel(KiloBundle.message("session.empty.loading")), + Centerizer.TYPE.BOTH, + ), BorderLayout.CENTER) + } messageBody = SessionMessageListPanel(controller.model, this) - scroll = JBScrollPane(emptyBody).apply { + scroll = JBScrollPane(progressBody).apply { border = JBUI.Borders.empty() verticalScrollBarPolicy = JBScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED horizontalScrollBarPolicy = JBScrollPane.HORIZONTAL_SCROLLBAR_NEVER @@ -154,8 +164,17 @@ class SessionUi( prompt.setReady(m.isReady()) } - is SessionControllerEvent.ViewChanged -> { - showBody(if (event.show) messageBody else emptyBody) + is SessionControllerEvent.ViewChanged.ShowProgress -> { + showBody(progressBody) + } + + is SessionControllerEvent.ViewChanged.ShowRecents -> { + val panel = EmptySessionPanel(this, controller, event.recents) + showBody(panel) + } + + is SessionControllerEvent.ViewChanged.ShowSession -> { + showBody(messageBody) } is SessionControllerEvent.AppChanged, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUiFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUiFactory.kt index ef5151d1874..6707edf97a7 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUiFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUiFactory.kt @@ -26,7 +26,7 @@ class SessionUiFactory( app = service(), cs = scope(), id = id, - onOpenSession = manager::openSession, + open = manager::openSession, ) private fun scope(): CoroutineScope { 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 1cfe45aa5c6..7757d1e54cf 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 @@ -2,23 +2,30 @@ package ai.kilocode.client.session.ui import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.update.SessionController -import ai.kilocode.client.session.update.SessionControllerEvent -import ai.kilocode.client.session.update.SessionControllerListener import ai.kilocode.client.ui.md.MdView import ai.kilocode.rpc.dto.SessionDto import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer import com.intellij.openapi.util.IconLoader import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBList import com.intellij.util.ui.Centerizer import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil import java.awt.BorderLayout +import java.awt.Component import java.awt.Dimension +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import java.awt.event.MouseMotionAdapter import javax.swing.Box import javax.swing.BoxLayout +import javax.swing.DefaultListModel +import javax.swing.JList import javax.swing.JPanel -import javax.swing.border.EmptyBorder +import javax.swing.ListCellRenderer +import javax.swing.ListSelectionModel +import kotlin.math.abs /** * Centered empty-session panel. @@ -26,138 +33,204 @@ import javax.swing.border.EmptyBorder class EmptySessionPanel( parent: Disposable, private val controller: SessionController, - open: (SessionDto) -> Unit = {}, -) : JPanel(BorderLayout()), SessionControllerListener, Disposable { + recents: List, +) : JPanel(BorderLayout()), Disposable { companion object { internal const val LIMIT = 5 internal const val MAX_WIDTH = 350 + private const val MINUTE = 60_000L + private const val HOUR = 60 * MINUTE + private const val DAY = 24 * HOUR } - init { - Disposer.register(parent, this) - controller.addListener(this, this) - } + private val model = DefaultListModel() + private var hover = -1 + + private val list = JBList(model).apply { + isOpaque = false + selectionMode = ListSelectionModel.SINGLE_SELECTION + visibleRowCount = LIMIT + cellRenderer = SessionRenderer() + emptyText.clear() + addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + val index = row(e) + if (index < 0) return + selectedIndex = index + controller.openSession(model.getElementAt(index)) + } - private val list = SessionListPanel(LIMIT, open) + override fun mouseExited(e: MouseEvent) { + hover = -1 + repaint() + } + }) + addMouseMotionListener(object : MouseMotionAdapter() { + override fun mouseMoved(e: MouseEvent) { + val index = row(e) + if (hover == index) return + hover = index + repaint() + } + }) + } private val md = MdView.html().apply { opaque = false foreground = UIUtil.getContextHelpForeground() set(KiloBundle.message("session.empty.welcome")) } + private val content = createContent() - private val logo = JBLabel( - IconLoader.getIcon("/icons/kilo-content.svg", EmptySessionPanel::class.java), - ).apply { - alignmentX = CENTER_ALIGNMENT - } - - private val intro = JPanel(BorderLayout()).apply { - isOpaque = false - alignmentX = CENTER_ALIGNMENT - add(md.component, BorderLayout.CENTER) - border = JBUI.Borders.empty(0, 12, 0, 12) - } - - private val recent = JPanel(BorderLayout()).apply { + init { + Disposer.register(parent, this) isOpaque = false - alignmentX = CENTER_ALIGNMENT - isVisible = false - add(JBLabel(KiloBundle.message("session.empty.recent")).apply { - foreground = UIUtil.getContextHelpForeground() - font = font.deriveFont(font.size2D - 1f) - border = JBUI.Borders.emptyLeft(8) - }, BorderLayout.NORTH) - add(list, BorderLayout.CENTER) + border = JBUI.Borders.empty(12) + setSessions(recents) + add(Centerizer(content, Centerizer.TYPE.BOTH), BorderLayout.CENTER) } - private val stack = JPanel().apply { - isOpaque = false - layout = BoxLayout(this, BoxLayout.Y_AXIS) - add(logo) - add(Box.createVerticalStrut(JBUI.scale(14))) - add(intro) - add(Box.createVerticalStrut(JBUI.scale(28))) - add(recent) + private fun setSessions(sessions: List) { + model.clear() + sessions.take(LIMIT).forEach(model::addElement) + revalidate() + repaint() } - private val content = object : JPanel(BorderLayout()) { - override fun getPreferredSize(): Dimension { - val size = super.getPreferredSize() - return Dimension(JBUI.scale(MAX_WIDTH), size.height) + private fun createContent(): JPanel { + val logo = JBLabel( + IconLoader.getIcon("/icons/kilo-content.svg", EmptySessionPanel::class.java), + ).apply { + alignmentX = CENTER_ALIGNMENT + } + val intro = JPanel(BorderLayout()).apply { + isOpaque = false + alignmentX = CENTER_ALIGNMENT + add(md.component, BorderLayout.CENTER) + border = JBUI.Borders.empty(0, 12, 0, 12) + } + val recent = JPanel(BorderLayout()).apply { + isOpaque = false + alignmentX = CENTER_ALIGNMENT + add(JBLabel(KiloBundle.message("session.empty.recent")).apply { + foreground = UIUtil.getContextHelpForeground() + font = font.deriveFont(font.size2D - 1f) + border = JBUI.Borders.emptyLeft(8) + }, BorderLayout.NORTH) + add(list, BorderLayout.CENTER) + } + val stack = JPanel().apply { + isOpaque = false + layout = BoxLayout(this, BoxLayout.Y_AXIS) + add(logo) + add(Box.createVerticalStrut(JBUI.scale(14))) + add(intro) + add(Box.createVerticalStrut(JBUI.scale(28))) + add(recent) + } + return object : JPanel(BorderLayout()) { + override fun getPreferredSize(): Dimension { + val size = super.getPreferredSize() + return Dimension(JBUI.scale(MAX_WIDTH), size.height) + } + }.apply { + isOpaque = false + add(stack, BorderLayout.NORTH) } - }.apply { - isOpaque = false - add(stack, BorderLayout.NORTH) } - private var loading = false + internal fun recentCount() = model.size() - init { - isOpaque = false - border = JBUI.Borders.empty(12) - add(Centerizer(content, Centerizer.TYPE.BOTH), BorderLayout.CENTER) - refresh() + internal fun selectRecent(index: Int) { + list.selectedIndex = index } - override fun addNotify() { - super.addNotify() - refresh(force = true) + internal fun selectedRecent() = list.selectedIndex + + internal fun clickRecent(index: Int) { + list.selectedIndex = index + controller.openSession(model.getElementAt(index)) } - override fun onEvent(event: SessionControllerEvent) { - when (event) { - is SessionControllerEvent.AppChanged, - is SessionControllerEvent.WorkspaceChanged, - is SessionControllerEvent.WorkspaceReady -> { - if (controller.ready) refresh() - } + internal fun recentVisible() = true - is SessionControllerEvent.ViewChanged -> { - if (!event.show) refresh(force = true) - } - } - } + internal fun explanationMarkdown() = md.markdown() - fun refresh(force: Boolean = false) { - if (!force && loading) return - loading = true - controller.recent( - limit = LIMIT, - onResult = { - loading = false - setSessions(it) - }, - onError = { - loading = false - }, - ) - } + internal fun contentPreferredSize() = content.preferredSize - internal fun setSessions(sessions: List) { - list.setSessions(sessions) - recent.isVisible = list.count() > 0 - revalidate() - repaint() - } + internal fun initialized() = true - internal fun recentCount() = list.count() + internal fun loadingVisible() = false - internal fun selectRecent(index: Int) { - list.select(index) + internal fun activeView() = getComponent(0) + + internal fun text(session: SessionDto, now: Long = System.currentTimeMillis()) = time(session, now) + + internal fun normalize(value: Double): Long { + val raw = value.toLong() + if (abs(raw) < 10_000_000_000L) return raw * 1000 + return raw } - internal fun selectedRecent() = list.selected() + internal fun rendererComponent( + session: SessionDto, + selected: Boolean = false, + hover: Boolean = false, + ): Component { + val old = this.hover + this.hover = if (hover) 0 else -1 + return list.cellRenderer.getListCellRendererComponent(list, session, 0, selected, false).also { + this.hover = old + } + } - internal fun clickRecent(index: Int) { - list.click(index) + private fun row(e: MouseEvent): Int { + val index = list.locationToIndex(e.point) + if (index < 0) return -1 + val box = list.getCellBounds(index, index) ?: return -1 + if (!box.contains(e.point)) return -1 + return index } - internal fun recentVisible() = recent.isVisible + private inner class SessionRenderer : JPanel(BorderLayout()), ListCellRenderer { + private val title = JBLabel() + private val time = JBLabel() - internal fun explanationMarkdown() = md.markdown() + init { + border = JBUI.Borders.empty(8, 8, 8, 8) + add(title, BorderLayout.CENTER) + add(time, BorderLayout.EAST) + } - internal fun contentPreferredSize() = content.preferredSize + override fun getListCellRendererComponent( + list: JList, + value: SessionDto?, + index: Int, + selected: Boolean, + focus: Boolean, + ): Component { + val active = selected || hover == index + isOpaque = active + background = if (active) list.selectionBackground else list.background + title.foreground = if (active) list.selectionForeground else UIUtil.getLabelForeground() + time.foreground = if (active) list.selectionForeground else UIUtil.getContextHelpForeground() + title.text = value?.let(::title) ?: "" + time.text = value?.let { time(it) } ?: "" + return this + } + } + + private fun title(session: SessionDto) = + session.title.takeIf { it.isNotBlank() } ?: KiloBundle.message("session.tab.untitled") + + private fun time(session: SessionDto, now: Long = System.currentTimeMillis()): String { + val ms = normalize(session.time.updated) + val diff = (now - ms).coerceAtLeast(0) + if (diff < MINUTE) return KiloBundle.message("session.empty.time.moments") + if (diff < HOUR) return KiloBundle.message("session.empty.time.minutes", (diff / MINUTE).coerceAtLeast(1)) + if (diff < DAY) return KiloBundle.message("session.empty.time.hours", (diff / HOUR).coerceAtLeast(1)) + return KiloBundle.message("session.empty.time.days", (diff / DAY).coerceAtLeast(1)) + } override fun dispose() { // no-op diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionListPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionListPanel.kt deleted file mode 100644 index d24aab10911..00000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionListPanel.kt +++ /dev/null @@ -1,155 +0,0 @@ -package ai.kilocode.client.session.ui - -import ai.kilocode.client.plugin.KiloBundle -import ai.kilocode.rpc.dto.SessionDto -import com.intellij.ui.components.JBLabel -import com.intellij.ui.components.JBList -import com.intellij.util.ui.JBUI -import com.intellij.util.ui.UIUtil -import java.awt.BorderLayout -import java.awt.Component -import java.awt.event.MouseAdapter -import java.awt.event.MouseEvent -import java.awt.event.MouseMotionAdapter -import javax.swing.DefaultListModel -import javax.swing.JList -import javax.swing.JPanel -import javax.swing.ListCellRenderer -import javax.swing.ListSelectionModel -import kotlin.math.abs - -class SessionListPanel( - private val limit: Int, - private val open: (SessionDto) -> Unit, -) : JPanel(BorderLayout()) { - companion object { - private const val MINUTE = 60_000L - private const val HOUR = 60 * MINUTE - private const val DAY = 24 * HOUR - } - - private val model = DefaultListModel() - private var hover = -1 - - private val list = JBList(model).apply { - isOpaque = false - selectionMode = ListSelectionModel.SINGLE_SELECTION - visibleRowCount = limit - cellRenderer = SessionRenderer() - emptyText.clear() - addMouseListener(object : MouseAdapter() { - override fun mouseClicked(e: MouseEvent) { - val index = row(e) - if (index < 0) return - selectedIndex = index - open(model.getElementAt(index)) - } - - override fun mouseExited(e: MouseEvent) { - hover = -1 - repaint() - } - }) - addMouseMotionListener(object : MouseMotionAdapter() { - override fun mouseMoved(e: MouseEvent) { - val index = row(e) - if (hover == index) return - hover = index - repaint() - } - }) - } - - init { - isOpaque = false - add(list, BorderLayout.CENTER) - } - - fun setSessions(sessions: List) { - model.clear() - sessions.take(limit).forEach(model::addElement) - revalidate() - repaint() - } - - fun count() = model.size() - - internal fun select(index: Int) { - list.selectedIndex = index - } - - internal fun selected() = list.selectedIndex - - internal fun click(index: Int) { - list.selectedIndex = index - open(model.getElementAt(index)) - } - - internal fun text(session: SessionDto, now: Long = System.currentTimeMillis()) = time(session, now) - - internal fun normalize(value: Double): Long { - val raw = value.toLong() - if (abs(raw) < 10_000_000_000L) return raw * 1000 - return raw - } - - internal fun rendererComponent( - session: SessionDto, - selected: Boolean = false, - hover: Boolean = false, - ): Component { - val old = this.hover - this.hover = if (hover) 0 else -1 - return list.cellRenderer.getListCellRendererComponent(list, session, 0, selected, false).also { - this.hover = old - } - } - - private fun row(e: MouseEvent): Int { - val index = list.locationToIndex(e.point) - if (index < 0) return -1 - val box = list.getCellBounds(index, index) ?: return -1 - if (!box.contains(e.point)) return -1 - return index - } - - private inner class SessionRenderer : JPanel(BorderLayout()), ListCellRenderer { - private val title = JBLabel() - private val time = JBLabel() - - init { - border = JBUI.Borders.empty(8, 8, 8, 8) - add(title, BorderLayout.CENTER) - add(time, BorderLayout.EAST) - } - - override fun getListCellRendererComponent( - list: JList, - value: SessionDto?, - index: Int, - selected: Boolean, - focus: Boolean, - ): Component { - val active = selected || hover == index - isOpaque = active - background = if (active) list.selectionBackground else list.background - title.foreground = if (active) list.selectionForeground else UIUtil.getLabelForeground() - time.foreground = if (active) list.selectionForeground else UIUtil.getContextHelpForeground() - title.text = value?.let(::title) ?: "" - time.text = value?.let { time(it) } ?: "" - return this - } - } - - private fun title(session: SessionDto) = - session.title.takeIf { it.isNotBlank() } ?: KiloBundle.message("session.tab.untitled") - - private fun time(session: SessionDto, now: Long = System.currentTimeMillis()): String { - val ms = normalize(session.time.updated) - val diff = (now - ms).coerceAtLeast(0) - if (diff < MINUTE) return KiloBundle.message("session.empty.time.moments") - if (diff < HOUR) return KiloBundle.message("session.empty.time.minutes", (diff / MINUTE).coerceAtLeast(1)) - if (diff < DAY) return KiloBundle.message("session.empty.time.hours", (diff / HOUR).coerceAtLeast(1)) - return KiloBundle.message("session.empty.time.days", (diff / DAY).coerceAtLeast(1)) - } -} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt index 76341d30749..fa30730c989 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt @@ -60,10 +60,12 @@ class SessionController( comp: Component? = null, private val flushMs: Long = EVENT_FLUSH_MS, private val condense: Boolean = true, + private val open: (SessionDto) -> Unit = {}, ) : Disposable { companion object { private val LOG = KiloLog.create(SessionController::class.java) + internal const val RECENT_LIMIT = 5 } init { @@ -87,25 +89,43 @@ class SessionController( private var partType: String? = null private var tool: String? = null private var eventJob: Job? = null + private var recentsLoading = false + private var recentsLoaded = false + private var view: SessionControllerEvent.ViewChanged? = null val ready: Boolean get() = model.isReady() - fun recent( - limit: Int, - onResult: (List) -> Unit, - onError: () -> Unit = {}, - ) { + fun refreshRecents(force: Boolean = false) { + if (model.showMessages) return + if (recentsLoading) return + if (recentsLoaded && !force) return + recentsLoading = true + view(SessionControllerEvent.ViewChanged.ShowProgress) cs.launch { try { - val items = sessions.recent(directory, limit) - edt { onResult(items) } + val items = sessions.recent(directory, RECENT_LIMIT) + edt { + recentsLoading = false + recentsLoaded = true + if (model.showMessages) return@edt + view(SessionControllerEvent.ViewChanged.ShowRecents(items)) + } } catch (e: Exception) { LOG.warn("kind=session-recent dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) - edt { onError() } + edt { + recentsLoading = false + recentsLoaded = true + if (model.showMessages) return@edt + view(SessionControllerEvent.ViewChanged.ShowRecents(emptyList())) + } } } } + fun openSession(session: SessionDto) { + open(session) + } + fun addListener(parent: Disposable, listener: SessionControllerListener) { listeners.add(listener) Disposer.register(parent) { listeners.remove(listener) } @@ -286,6 +306,9 @@ class SessionController( if (state.status == KiloWorkspaceStatusDto.READY) { fire(SessionControllerEvent.WorkspaceReady) + edt { + if (sessionId == null) refreshRecents() + } } } } @@ -294,16 +317,24 @@ class SessionController( private fun loadHistory() { val id = sessionId ?: return cs.launch { + view(SessionControllerEvent.ViewChanged.ShowProgress) try { val history = sessions.messages(id, directory) LOG.debug { "${ChatLogSummary.sid(id)} ${ChatLogSummary.history(history)}" } runEdt { this@SessionController.model.loadHistory(history) - if (!model.isEmpty()) showMessages() } recoverPending(id) + edt { + if (!model.isEmpty()) { + showMessages() + return@edt + } + refreshRecents(force = true) + } } catch (e: Exception) { LOG.warn("${ChatLogSummary.sid(id)} kind=history dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) + edt { refreshRecents(force = true) } } finally { updates.holdFlush(false) updates.requestFlush(true) @@ -519,10 +550,16 @@ class SessionController( private fun showMessages() { if (!model.showMessages) { model.showMessages = true - fire(SessionControllerEvent.ViewChanged(true)) + view(SessionControllerEvent.ViewChanged.ShowSession) } } + private fun view(event: SessionControllerEvent.ViewChanged) { + if (view == event) return + view = event + fire(event) + } + private fun status(): String = when (partType) { "reasoning" -> KiloBundle.message("session.status.thinking") "text" -> KiloBundle.message("session.status.writing") diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionControllerEvent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionControllerEvent.kt index b872969180f..c11ff6ec532 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionControllerEvent.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionControllerEvent.kt @@ -2,6 +2,7 @@ package ai.kilocode.client.session.update import ai.kilocode.client.session.model.SessionModel import ai.kilocode.client.session.model.SessionModelEvent +import ai.kilocode.rpc.dto.SessionDto /** * Lifecycle events fired by [SessionController] on the EDT. @@ -18,8 +19,19 @@ sealed class SessionControllerEvent { // Workspace ready (pickers populated) data object WorkspaceReady : SessionControllerEvent() - data class ViewChanged(val show: Boolean) : SessionControllerEvent() { - override fun toString() = if (show) "ViewChanged show" else "ViewChanged hide" + + sealed class ViewChanged : SessionControllerEvent() { + data object ShowProgress : ViewChanged() { + override fun toString() = "ViewChanged progress" + } + + data class ShowRecents(val recents: List) : ViewChanged() { + override fun toString() = "ViewChanged recents=${recents.size}" + } + + data object ShowSession : ViewChanged() { + override fun toString() = "ViewChanged session" + } } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt index fc1d4ad8862..5de11cff2c2 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt @@ -110,7 +110,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { root = workspace, create = { project, workspace, owner, id -> created.add(workspace.directory to id) - SessionUi(project, workspace, sessions, app, scope, id = id, onOpenSession = owner::openSession) + SessionUi(project, workspace, sessions, app, scope, id = id, open = owner::openSession) }, resolve = { workspaces.workspace(it) }, ) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt index 183f44df951..4f7287c3161 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt @@ -55,39 +55,39 @@ class SessionUiFactoryTest : BasePlatformTestCase() { fun `test factory wires open callback`() { val manager = FakeManager() - val ui = SessionUi(project, workspace, sessions, app, scope, onOpenSession = manager::openSession) - val panel = find(ui) + val rpc = session("ses_1") + val ui = SessionUi(project, workspace, sessions, app, scope, open = manager::openSession) + val controller = controller(ui) + + controller.openSession(rpc) - panel.onEvent(ai.kilocode.client.session.update.SessionControllerEvent.WorkspaceReady) + assertEquals(listOf("ses_1"), manager.opened) + } + + fun `test empty panel opens through controller`() { + val manager = FakeManager() val rpc = session("ses_1") - panel.setSessions(listOf(rpc)) + val ui = SessionUi(project, workspace, sessions, app, scope, open = manager::openSession) + val controller = controller(ui) + val panel = ai.kilocode.client.session.ui.EmptySessionPanel(testRootDisposable, controller, listOf(rpc)) + panel.clickRecent(0) assertEquals(listOf("ses_1"), manager.opened) } + private fun controller(ui: SessionUi): ai.kilocode.client.session.update.SessionController { + val field = SessionUi::class.java.getDeclaredField("controller") + field.isAccessible = true + return field.get(ui) as ai.kilocode.client.session.update.SessionController + } + fun `test application service is available`() { assertNotNull(service()) } private fun direct() = SessionUiFactory(scope) - private inline fun find(root: java.awt.Container): T { - return find(root, T::class.java) ?: error("missing ${T::class.java.simpleName}") - } - - private fun find(root: java.awt.Container, cls: Class): T? { - if (cls.isInstance(root)) return cls.cast(root) - for (child in root.components) { - if (cls.isInstance(child)) return cls.cast(child) - if (child is java.awt.Container) { - val item = find(child, cls) - if (item != null) return item - } - } - return null - } - private fun session(id: String) = SessionDto( id = id, projectID = "prj", diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt index cfeee3caa63..6b18b94f604 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt @@ -160,6 +160,7 @@ class SessionUiLayoutTest : BasePlatformTestCase() { } fun `test empty and message bodies share the same scroll pane`() { + settle() val scroll = find(ui) val empty = find(ui) @@ -175,11 +176,12 @@ class SessionUiLayoutTest : BasePlatformTestCase() { fun `test clicking recent session calls opener`() { val opened = mutableListOf() rpc.recent.add(session("ses_1")) - ui = SessionUi(project, workspace, sessions, app, scope, onOpenSession = { opened.add(it.id) }).apply { + ui = SessionUi(project, workspace, sessions, app, scope, open = { opened.add(it.id) }).apply { setSize(800, 600) } settle() + layout() find(ui).clickRecent(0) assertEquals(listOf("ses_1"), opened) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt index 2068d94fcc7..50ee03c03af 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt @@ -5,7 +5,6 @@ import ai.kilocode.client.app.KiloSessionService import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.app.Workspace import ai.kilocode.client.session.update.SessionController -import ai.kilocode.client.session.update.SessionControllerEvent import ai.kilocode.client.testing.FakeAppRpcApi import ai.kilocode.client.testing.FakeSessionRpcApi import ai.kilocode.client.testing.FakeWorkspaceRpcApi @@ -19,21 +18,20 @@ import com.intellij.testFramework.fixtures.BasePlatformTestCase import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay -import kotlinx.coroutines.runBlocking +import java.awt.BorderLayout +import javax.swing.JPanel @Suppress("UnstableApiUsage") class EmptySessionPanelTest : BasePlatformTestCase() { private lateinit var scope: CoroutineScope - private lateinit var rpc: FakeSessionRpcApi private lateinit var app: KiloAppService private lateinit var workspace: Workspace private lateinit var controller: SessionController + private val opened = mutableListOf() override fun setUp() { super.setUp() scope = CoroutineScope(SupervisorJob()) - rpc = FakeSessionRpcApi() app = KiloAppService(scope, FakeAppRpcApi().also { it.state.value = KiloAppStateDto(KiloAppStatusDto.READY) }) @@ -44,10 +42,11 @@ class EmptySessionPanelTest : BasePlatformTestCase() { controller = SessionController( parent = testRootDisposable, id = null, - sessions = KiloSessionService(project, scope, rpc), + sessions = KiloSessionService(project, scope, FakeSessionRpcApi()), workspace = workspace, app = app, cs = scope, + open = { opened.add(it.id) }, ) } @@ -59,12 +58,17 @@ class EmptySessionPanelTest : BasePlatformTestCase() { } } - fun `test recent section is hidden when empty`() { + fun `test content is initialized immediately`() { val panel = panel() - panel.setSessions(emptyList()) + assertTrue(panel.initialized()) + assertFalse(panel.loadingVisible()) + } + + fun `test recent section remains visible when empty`() { + val panel = panel() - assertFalse(panel.recentVisible()) + assertTrue(panel.recentVisible()) assertEquals(0, panel.recentCount()) } @@ -82,9 +86,7 @@ class EmptySessionPanelTest : BasePlatformTestCase() { } fun `test recent sessions are capped at five`() { - val panel = panel() - - panel.setSessions((1..7).map { session("ses_$it") }) + val panel = panel((1..7).map { session("ses_$it") }) assertTrue(panel.recentVisible()) assertEquals(5, panel.recentCount()) @@ -99,60 +101,68 @@ class EmptySessionPanelTest : BasePlatformTestCase() { ) } - fun `test panel loads recent sessions`() { - rpc.recent.add(session("ses_1")) - val panel = panel() + fun `test selecting recent session does not open it`() { + val panel = panel(listOf(session("ses_1"), session("ses_2"))) - settle() + panel.selectRecent(1) - assertTrue(rpc.recentCalls.contains("/test" to 5)) - assertTrue(panel.recentVisible()) - assertEquals(1, panel.recentCount()) + assertEquals(1, panel.selectedRecent()) + assertEquals(emptyList(), opened) } - fun `test panel retries recent sessions when controller becomes ready`() { - rpc.recentFailures = 1 - rpc.recent.add(session("ses_1")) - val panel = panel() + fun `test clicking recent session delegates to controller`() { + val panel = panel(listOf(session("ses_1"), session("ses_2"))) - settle() - panel.onEvent(SessionControllerEvent.WorkspaceReady) - settle() + panel.clickRecent(1) - assertTrue(rpc.recentCalls.size >= 2) - assertTrue(panel.recentVisible()) - assertEquals(1, panel.recentCount()) + assertEquals(listOf("ses_2"), opened) } - fun `test panel refreshes when shown after hide`() { - rpc.recent.add(session("ses_1")) + fun `test renderer aligns title center and time east`() { + val cell = panel().rendererComponent(session("ses_1")) as JPanel + val layout = cell.layout as BorderLayout + + assertNotNull(layout.getLayoutComponent(BorderLayout.CENTER)) + assertNotNull(layout.getLayoutComponent(BorderLayout.EAST)) + } + + fun `test hover uses selection colors`() { val panel = panel() - settle() - rpc.recent.clear() - rpc.recent.add(session("ses_2")) + val session = session("ses_1") + val selected = panel.rendererComponent(session, selected = true) as JPanel + val hovered = panel.rendererComponent(session, hover = true) as JPanel - panel.onEvent(SessionControllerEvent.ViewChanged(false)) - settle() + assertTrue(selected.isOpaque) + assertTrue(hovered.isOpaque) + assertEquals(selected.background, hovered.background) + } - assertTrue(rpc.recentCalls.size >= 2) - assertEquals(1, panel.recentCount()) + fun `test timestamp normalization handles seconds and milliseconds`() { + val panel = panel() + + assertEquals(1_700_000_000_000L, panel.normalize(1_700_000_000.0)) + assertEquals(1_700_000_000_000L, panel.normalize(1_700_000_000_000.0)) } - private fun panel(open: (SessionDto) -> Unit = {}) = EmptySessionPanel(testRootDisposable, controller, open) + fun `test timestamp renders coarse relative text`() { + val panel = panel() + val now = 1_700_000_000_000L - private fun settle() = runBlocking { - repeat(5) { - delay(100) - com.intellij.util.ui.UIUtil.dispatchAllInvocationEvents() - } + assertEquals("Moments ago", panel.text(session("ses_1", now - 30_000), now)) + assertEquals("2 min ago", panel.text(session("ses_1", now - 120_000), now)) + assertEquals("3h ago", panel.text(session("ses_1", now - 10_800_000), now)) + assertEquals("4d ago", panel.text(session("ses_1", now - 345_600_000), now)) } - private fun session(id: String) = SessionDto( + private fun panel(recents: List = emptyList()) = + EmptySessionPanel(testRootDisposable, controller, recents) + + private fun session(id: String, updated: Long = 2_000L) = SessionDto( id = id, projectID = "prj", directory = "/repo/$id", title = "Title $id", version = "1", - time = SessionTimeDto(created = 1.0, updated = 2.0), + time = SessionTimeDto(created = 1.0, updated = updated.toDouble()), ) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionListPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionListPanelTest.kt deleted file mode 100644 index cf7ee5e1a26..00000000000 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionListPanelTest.kt +++ /dev/null @@ -1,89 +0,0 @@ -package ai.kilocode.client.session.ui - -import ai.kilocode.rpc.dto.SessionDto -import ai.kilocode.rpc.dto.SessionTimeDto -import com.intellij.testFramework.fixtures.BasePlatformTestCase -import java.awt.BorderLayout -import javax.swing.JPanel - -@Suppress("UnstableApiUsage") -class SessionListPanelTest : BasePlatformTestCase() { - fun `test sessions are capped by constructor limit`() { - val panel = panel(limit = 5) - - panel.setSessions((1..7).map { session("ses_$it") }) - - assertEquals(5, panel.count()) - } - - fun `test selecting recent session does not invoke callback`() { - val clicked = mutableListOf() - val panel = panel { clicked.add(it.id) } - - panel.setSessions(listOf(session("ses_1"), session("ses_2"))) - panel.select(1) - - assertEquals(1, panel.selected()) - assertEquals(emptyList(), clicked) - } - - fun `test clicking recent session invokes callback`() { - val clicked = mutableListOf() - val panel = panel { clicked.add(it.id) } - - panel.setSessions(listOf(session("ses_1"), session("ses_2"))) - panel.click(1) - - assertEquals(listOf("ses_2"), clicked) - } - - fun `test renderer aligns title center and time east`() { - val cell = panel().rendererComponent(session("ses_1")) as JPanel - val layout = cell.layout as BorderLayout - - assertNotNull(layout.getLayoutComponent(BorderLayout.CENTER)) - assertNotNull(layout.getLayoutComponent(BorderLayout.EAST)) - } - - fun `test hover uses selection colors`() { - val panel = panel() - val session = session("ses_1") - val selected = panel.rendererComponent(session, selected = true) as JPanel - val hovered = panel.rendererComponent(session, hover = true) as JPanel - - assertTrue(selected.isOpaque) - assertTrue(hovered.isOpaque) - assertEquals(selected.background, hovered.background) - } - - fun `test timestamp normalization handles seconds and milliseconds`() { - val panel = panel() - - assertEquals(1_700_000_000_000L, panel.normalize(1_700_000_000.0)) - assertEquals(1_700_000_000_000L, panel.normalize(1_700_000_000_000.0)) - } - - fun `test timestamp renders coarse relative text`() { - val panel = panel() - val now = 1_700_000_000_000L - - assertEquals("Moments ago", panel.text(session("ses_1", now - 30_000), now)) - assertEquals("2 min ago", panel.text(session("ses_1", now - 120_000), now)) - assertEquals("3h ago", panel.text(session("ses_1", now - 10_800_000), now)) - assertEquals("4d ago", panel.text(session("ses_1", now - 345_600_000), now)) - } - - private fun panel( - limit: Int = 5, - open: (SessionDto) -> Unit = {}, - ) = SessionListPanel(limit, open) - - private fun session(id: String, updated: Long = 2_000L) = SessionDto( - id = id, - projectID = "prj", - directory = "/repo/$id", - title = "Title $id", - version = "1", - time = SessionTimeDto(created = 1.0, updated = updated.toDouble()), - ) -} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/HistoryLoadingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/HistoryLoadingTest.kt index 3f7acda9ed6..2ee8e5efc15 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/HistoryLoadingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/HistoryLoadingTest.kt @@ -29,8 +29,16 @@ class HistoryLoadingTest : SessionControllerTestBase() { rpc.history.add(MessageWithPartsDto(msg("msg1", "ses_test", "user"), emptyList())) val c = controller("ses_test") + val events = collect(c) flush() + assertControllerEvents(""" + AppChanged + WorkspaceChanged + ViewChanged progress + ViewChanged session + """, events) + assertSession( """ user#msg1 diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ListenerLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ListenerLifecycleTest.kt index 75800512232..e5d61e373fc 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ListenerLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ListenerLifecycleTest.kt @@ -24,7 +24,7 @@ class ListenerLifecycleTest : SessionControllerTestBase() { flush() assertControllerEvents(""" - ViewChanged show + ViewChanged session AppChanged WorkspaceChanged """, events) @@ -47,7 +47,7 @@ class ListenerLifecycleTest : SessionControllerTestBase() { assertEquals(events1, events2) assertControllerEvents(""" - ViewChanged show + ViewChanged session AppChanged WorkspaceChanged """, events1) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionCreationTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionCreationTest.kt index f89654996d5..36fe6220905 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionCreationTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionCreationTest.kt @@ -14,7 +14,7 @@ class SessionCreationTest : SessionControllerTestBase() { assertEquals(1, rpc.creates) assertEquals(1, rpc.prompts.size) assertEquals("ses_test", rpc.prompts[0].first) - assertControllerEvents("ViewChanged show", events) + assertControllerEvents("ViewChanged session", events) assertSession( """ [app: DISCONNECTED] [workspace: PENDING] diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ViewSwitchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ViewSwitchingTest.kt index 2edc4f7fbb9..1eff29033fb 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ViewSwitchingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ViewSwitchingTest.kt @@ -11,7 +11,7 @@ class ViewSwitchingTest : SessionControllerTestBase() { edt { m.prompt("hello") } flush() - assertControllerEvents("ViewChanged show", events) + assertControllerEvents("ViewChanged session", events) assertSession( """ [app: DISCONNECTED] [workspace: PENDING] @@ -31,6 +31,66 @@ class ViewSwitchingTest : SessionControllerTestBase() { edt { m.prompt("second") } flush() - assertControllerEvents("ViewChanged show", events) + assertControllerEvents("ViewChanged session", events) } + + fun `test recent sessions show after workspace ready`() { + projectRpc.state.value = workspaceReady() + rpc.recent.add(session("ses_1")) + val m = controller() + val events = collect(m) + + flush() + + assertTrue(rpc.recentCalls.contains("/test" to SessionController.RECENT_LIMIT)) + assertControllerEvents(""" + AppChanged + WorkspaceChanged + WorkspaceReady + ViewChanged progress + ViewChanged recents=1 + """, events) + } + + fun `test recent load failure shows empty recents`() { + projectRpc.state.value = workspaceReady() + rpc.recentFailures = 1 + val m = controller() + val events = collect(m) + + flush() + + assertTrue(rpc.recentCalls.contains("/test" to SessionController.RECENT_LIMIT)) + assertControllerEvents(""" + AppChanged + WorkspaceChanged + WorkspaceReady + ViewChanged progress + ViewChanged recents=0 + """, events) + } + + fun `test empty history transitions to recents`() { + rpc.recent.add(session("ses_1")) + val m = controller("ses_test") + val events = collect(m) + + flush() + + assertControllerEvents(""" + AppChanged + WorkspaceChanged + ViewChanged progress + ViewChanged recents=1 + """, events) + } + + private fun session(id: String) = ai.kilocode.rpc.dto.SessionDto( + id = id, + projectID = "prj", + directory = "/test", + title = "Title $id", + version = "1", + time = ai.kilocode.rpc.dto.SessionTimeDto(created = 1.0, updated = 2.0), + ) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/WorkspaceWatchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/WorkspaceWatchingTest.kt index 2aa2ce3f66c..0fd38ca981e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/WorkspaceWatchingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/WorkspaceWatchingTest.kt @@ -17,6 +17,8 @@ class WorkspaceWatchingTest : SessionControllerTestBase() { assertEquals("gpt-5", m.model.models[0].id) assertFalse(m.model.isReady()) assertControllerEvents(""" + ViewChanged progress + ViewChanged recents=0 WorkspaceChanged WorkspaceReady """, events) From 87609f2f9e21cccfa97fb4eb360a68ded7a0005d Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 28 Apr 2026 15:03:49 -0400 Subject: [PATCH 11/16] fix: avoid transient JetBrains session loading states --- .changeset/tidy-otters-wait.md | 5 + .../client/session/SessionSidePanelManager.kt | 10 +- .../ai/kilocode/client/session/SessionUi.kt | 46 ++++- .../client/session/SessionUiFactory.kt | 2 + .../client/session/model/SessionModel.kt | 2 +- .../client/session/ui/ConnectionPanel.kt | 80 ++------- .../client/session/update/DelayedState.kt | 63 +++++++ .../session/update/SessionController.kt | 167 ++++++++++++++++-- .../session/update/SessionControllerEvent.kt | 18 ++ .../resources/messages/KiloBundle.properties | 1 + .../session/SessionSidePanelManagerTest.kt | 37 +++- .../client/session/SessionUiFactoryTest.kt | 2 +- .../client/session/SessionUiLayoutTest.kt | 65 ++++++- .../client/session/ui/ConnectionPanelTest.kt | 65 +++---- .../session/update/ConnectionDelayTest.kt | 156 ++++++++++++++++ .../update/SessionControllerTestBase.kt | 32 +++- .../session/update/ViewSwitchingTest.kt | 79 ++++++++- .../session/update/WorkspaceWatchingTest.kt | 1 - .../client/testing/FakeSessionRpcApi.kt | 3 + 19 files changed, 676 insertions(+), 158 deletions(-) create mode 100644 .changeset/tidy-otters-wait.md create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/DelayedState.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ConnectionDelayTest.kt diff --git a/.changeset/tidy-otters-wait.md b/.changeset/tidy-otters-wait.md new file mode 100644 index 00000000000..151fcfe79af --- /dev/null +++ b/.changeset/tidy-otters-wait.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Avoid flickering transient loading and connection error states in JetBrains sessions. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt index e1c53a88d75..3c0e3f741e7 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt @@ -14,8 +14,8 @@ import javax.swing.JPanel class SessionSidePanelManager( private val project: Project, private val root: Workspace, - private val create: (Project, Workspace, SessionManager, String?) -> SessionUi = { project, workspace, manager, id -> - service().create(project, workspace, manager, id) + private val create: (Project, Workspace, SessionManager, String?, Boolean) -> SessionUi = { project, workspace, manager, id, loading -> + service().create(project, workspace, manager, id, loading) }, private val resolve: (String) -> Workspace = { dir -> service().workspace(dir) }, ) : SessionManager, Disposable { @@ -31,12 +31,14 @@ class SessionSidePanelManager( private var current: SessionUi? = null override fun newSession() { - show(create(project, root, this, null)) + val active = current + if (active?.blank == true) return + show(create(project, root, this, null, active == null)) } override fun openSession(session: SessionDto) { val ui = opened.getOrPut(session.id) { - create(project, resolve(session.directory), this, session.id).also { + create(project, resolve(session.directory), this, session.id, false).also { all.add(it) } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 6dbca2ca4f0..3c1fa7cd473 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -39,16 +39,41 @@ import javax.swing.JPanel * It builds the session panels, wires controller/model listeners, and swaps the * center body between the empty state and the message list. */ -class SessionUi( +class SessionUi private constructor( project: Project, workspace: Workspace, sessions: KiloSessionService, app: KiloAppService, cs: CoroutineScope, - id: String? = null, - open: (SessionDto) -> Unit = {}, + id: String?, + displayMs: Long, + open: (SessionDto) -> Unit, + private val loading: Boolean, ) : JPanel(BorderLayout()), Disposable { + constructor( + project: Project, + workspace: Workspace, + sessions: KiloSessionService, + app: KiloAppService, + cs: CoroutineScope, + id: String? = null, + displayMs: Long = SessionController.DISPLAY_DELAY_MS, + open: (SessionDto) -> Unit = {}, + ) : this(project, workspace, sessions, app, cs, id, displayMs, open, id == null) + + internal constructor( + project: Project, + workspace: Workspace, + sessions: KiloSessionService, + app: KiloAppService, + cs: CoroutineScope, + id: String? = null, + displayMs: Long = SessionController.DISPLAY_DELAY_MS, + loading: Boolean, + open: (SessionDto) -> Unit = {}, + ) : this(project, workspace, sessions, app, cs, id, displayMs, open, loading) + companion object { private val LOG = KiloLog.create(SessionUi::class.java) } @@ -64,6 +89,7 @@ class SessionUi( this, id, sessions, workspace, app, cs, this, flushMs = flushMs, condense = Registry.`is`("kilo.session.condense", true), + displayMs = displayMs, open = open, ) @@ -72,6 +98,8 @@ class SessionUi( private lateinit var sessionContent: JPanel + private lateinit var blankBody: JPanel + private lateinit var progressBody: JPanel private lateinit var messageBody: SessionMessageListPanel @@ -87,14 +115,20 @@ class SessionUi( init { buildUi() bindUi() - showBody(progressBody) + showBody(if (loading) progressBody else blankBody) } + internal val blank: Boolean get() = controller.blank + private fun buildUi() { root = SessionRootPanel() sessionContent = JPanel(BorderLayout()) + blankBody = JPanel(BorderLayout()).apply { + isOpaque = false + } + progressBody = JPanel(BorderLayout()).apply { isOpaque = false add(Centerizer( @@ -104,7 +138,7 @@ class SessionUi( } messageBody = SessionMessageListPanel(controller.model, this) - scroll = JBScrollPane(progressBody).apply { + scroll = JBScrollPane(blankBody).apply { border = JBUI.Borders.empty() verticalScrollBarPolicy = JBScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED horizontalScrollBarPolicy = JBScrollPane.HORIZONTAL_SCROLLBAR_NEVER @@ -181,6 +215,8 @@ class SessionUi( is SessionControllerEvent.WorkspaceChanged -> { prompt.setReady(controller.model.isReady()) } + + is SessionControllerEvent.ConnectionChanged -> Unit } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUiFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUiFactory.kt index 6707edf97a7..c55e607d926 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUiFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUiFactory.kt @@ -19,6 +19,7 @@ class SessionUiFactory( workspace: Workspace, manager: SessionManager, id: String? = null, + loading: Boolean = id == null, ): SessionUi = SessionUi( project = project, workspace = workspace, @@ -26,6 +27,7 @@ class SessionUiFactory( app = service(), cs = scope(), id = id, + loading = loading, open = manager::openSession, ) 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 0c93461c5e8..93a1560a676 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 @@ -46,7 +46,7 @@ class SessionModel { var models: List = emptyList() var agent: String? = null var model: String? = null - var showMessages: Boolean = false + var showSession: Boolean = false var state: SessionState = SessionState.Idle private set diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt index 794c593d730..c9e866887fc 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt @@ -4,10 +4,6 @@ import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.update.SessionController import ai.kilocode.client.session.update.SessionControllerEvent import ai.kilocode.client.session.update.SessionControllerListener -import ai.kilocode.rpc.dto.ConfigWarningDto -import ai.kilocode.rpc.dto.LoadErrorDto -import ai.kilocode.rpc.dto.KiloAppStatusDto -import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto import com.intellij.icons.AllIcons import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer @@ -99,48 +95,30 @@ class ConnectionPanel( header.add(retry, BorderLayout.EAST) add(header, BorderLayout.NORTH) controller.addListener(this, this) - render() + hidePanel() } override fun onEvent(event: SessionControllerEvent) { when (event) { - is SessionControllerEvent.AppChanged, - is SessionControllerEvent.WorkspaceChanged -> render() + is SessionControllerEvent.ConnectionChanged.Hide -> hidePanel() - else -> Unit - } - } - - private fun render() { - val app = controller.model.app - val workspace = controller.model.workspace - - if (app.status == KiloAppStatusDto.ERROR) { - showError( - app.error ?: KiloBundle.message("session.connection.error.unknown"), - app.errors.toErrorText(), - ) - showPanel() - return - } + is SessionControllerEvent.ConnectionChanged.ShowConnecting -> showConnecting() - if (workspace.status == KiloWorkspaceStatusDto.ERROR) { - showError(workspace.error ?: KiloBundle.message("session.connection.error.unknown"), null) - showPanel() - return - } + is SessionControllerEvent.ConnectionChanged.ShowError -> { + showError(event.summary, event.detail) + showPanel() + } - if (app.status == KiloAppStatusDto.READY && workspace.status == KiloWorkspaceStatusDto.READY && app.warnings.isNotEmpty()) { - showWarning(summary(app.warnings.size), app.warnings.toWarningText()) - showPanel() - return - } + is SessionControllerEvent.ConnectionChanged.ShowWarning -> { + showWarning(event.summary, event.detail) + showPanel() + } - if (app.status == KiloAppStatusDto.READY && workspace.status == KiloWorkspaceStatusDto.READY) { - hidePanel() - return + else -> Unit } + } + private fun showConnecting() { label.foreground = UIUtil.getContextHelpForeground() label.text = KiloBundle.message("session.connection.connecting") detail = null @@ -171,12 +149,6 @@ class ConnectionPanel( renderDetails() } - private fun summary(count: Int): String { - val base = KiloBundle.message("session.connection.warning.config") - if (count <= 1) return base - return "$base ($count)" - } - private fun renderDetails() { val text = detail val show = expanded && text != null @@ -272,27 +244,3 @@ class ConnectionPanel( internal fun maxExpandedHeight() = header.preferredSize.height + details.getFontMetrics(details.font).height * DETAILS_LINES + scrollChrome() } - -private fun List.toErrorText(): String? { - val out = mapNotNull { it.toDetailLine() } - if (out.isEmpty()) return null - return out.joinToString("\n") -} - -private fun List.toWarningText(): String? { - val out = mapNotNull { it.toDetailLine() } - if (out.isEmpty()) return null - return out.joinToString("\n\n") -} - -private fun LoadErrorDto.toDetailLine(): String? { - val detail = detail?.trim()?.ifEmpty { null } ?: return null - if (resource == "connection") return detail - return "$resource: $detail" -} - -private fun ConfigWarningDto.toDetailLine(): String { - val head = "$path: $message" - val tail = detail?.trim()?.ifEmpty { null } ?: return head - return "$head\n$tail" -} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/DelayedState.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/DelayedState.kt new file mode 100644 index 00000000000..99647496ffa --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/DelayedState.kt @@ -0,0 +1,63 @@ +package ai.kilocode.client.session.update + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import javax.swing.Timer + +internal class DelayedState( + private val ms: Long, + private val current: () -> T, +) : Disposable { + private var timer: Timer? = null + private var state: T? = null + @Volatile private var alive = true + + fun run(state: T, action: (T) -> Unit) { + edt { + if (!alive) return@edt + timer?.stop() + this.state = state + if (ms <= 0) { + apply(state, action) + return@edt + } + timer = Timer(ms.coerceAtMost(Int.MAX_VALUE.toLong()).toInt()) { + apply(state, action) + }.apply { + isRepeats = false + start() + } + } + } + + fun cancel() { + edt { + timer?.stop() + timer = null + state = null + } + } + + private fun apply(state: T, action: (T) -> Unit) { + if (!alive) return + if (this.state != state) return + if (current() != state) return + this.state = null + timer = null + action(state) + } + + private fun edt(block: () -> Unit) { + val app = ApplicationManager.getApplication() + if (app.isDispatchThread) { + block() + return + } + app.invokeLater(block) + } + + override fun dispose() { + alive = false + cancel() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt index fa30730c989..58950e52757 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt @@ -17,9 +17,11 @@ import ai.kilocode.client.session.model.QuestionItem import ai.kilocode.client.session.model.QuestionOption import ai.kilocode.client.session.model.ToolCallRef import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.ConfigWarningDto import ai.kilocode.rpc.dto.ConfigUpdateDto import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import ai.kilocode.rpc.dto.LoadErrorDto import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto import ai.kilocode.rpc.dto.PermissionReplyDto import ai.kilocode.rpc.dto.PermissionRequestDto @@ -60,12 +62,14 @@ class SessionController( comp: Component? = null, private val flushMs: Long = EVENT_FLUSH_MS, private val condense: Boolean = true, + private val displayMs: Long = DISPLAY_DELAY_MS, private val open: (SessionDto) -> Unit = {}, ) : Disposable { companion object { private val LOG = KiloLog.create(SessionController::class.java) internal const val RECENT_LIMIT = 5 + internal const val DISPLAY_DELAY_MS = 1_000L } init { @@ -89,33 +93,43 @@ class SessionController( private var partType: String? = null private var tool: String? = null private var eventJob: Job? = null - private var recentsLoading = false - private var recentsLoaded = false + private var history: HistoryState = HistoryState.Idle + private var recents: RecentsState = RecentsState.Idle private var view: SessionControllerEvent.ViewChanged? = null + private var conn: SessionControllerEvent.ConnectionChanged? = null + private val connDelay = DelayedState(displayMs, ::connectionState) + private val historyDelay = DelayedState(displayMs) { history } + private val recentsDelay = DelayedState(displayMs) { recents } val ready: Boolean get() = model.isReady() + internal val blank: Boolean get() = sessionId == null && model.isEmpty() && !model.showSession fun refreshRecents(force: Boolean = false) { - if (model.showMessages) return - if (recentsLoading) return - if (recentsLoaded && !force) return - recentsLoading = true - view(SessionControllerEvent.ViewChanged.ShowProgress) + if (model.showSession) return + if (recents is RecentsState.Loading) return + if (recents is RecentsState.Loaded && !force) return + val state = RecentsState.Loading() + recents = state + recentsDelay.run(state) { + view(SessionControllerEvent.ViewChanged.ShowProgress) + } cs.launch { try { val items = sessions.recent(directory, RECENT_LIMIT) edt { - recentsLoading = false - recentsLoaded = true - if (model.showMessages) return@edt + if (recents != state) return@edt + recentsDelay.cancel() + recents = RecentsState.Loaded + if (model.showSession) return@edt view(SessionControllerEvent.ViewChanged.ShowRecents(items)) } } catch (e: Exception) { LOG.warn("kind=session-recent dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) edt { - recentsLoading = false - recentsLoaded = true - if (model.showMessages) return@edt + if (recents != state) return@edt + recentsDelay.cancel() + recents = RecentsState.Loaded + if (model.showSession) return@edt view(SessionControllerEvent.ViewChanged.ShowRecents(emptyList())) } } @@ -273,6 +287,7 @@ class SessionController( fire(SessionControllerEvent.AppChanged) { model.app = state model.version = app.version + syncConnection() } } } @@ -281,6 +296,7 @@ class SessionController( workspace.state.collect { state -> fire(SessionControllerEvent.WorkspaceChanged) { model.workspace = state + syncConnection() if (state.status != KiloWorkspaceStatusDto.READY) return@fire @@ -317,7 +333,13 @@ class SessionController( private fun loadHistory() { val id = sessionId ?: return cs.launch { - view(SessionControllerEvent.ViewChanged.ShowProgress) + val state = HistoryState.Loading() + runEdt { + history = state + } + historyDelay.run(state) { + if (!model.showSession) view(SessionControllerEvent.ViewChanged.ShowProgress) + } try { val history = sessions.messages(id, directory) LOG.debug { "${ChatLogSummary.sid(id)} ${ChatLogSummary.history(history)}" } @@ -336,6 +358,11 @@ class SessionController( LOG.warn("${ChatLogSummary.sid(id)} kind=history dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) edt { refreshRecents(force = true) } } finally { + edt { + if (history != state) return@edt + historyDelay.cancel() + history = HistoryState.Idle + } updates.holdFlush(false) updates.requestFlush(true) } @@ -548,8 +575,12 @@ class SessionController( } private fun showMessages() { - if (!model.showMessages) { - model.showMessages = true + if (!model.showSession) { + model.showSession = true + historyDelay.cancel() + history = HistoryState.Idle + recentsDelay.cancel() + recents = RecentsState.Idle view(SessionControllerEvent.ViewChanged.ShowSession) } } @@ -560,6 +591,66 @@ class SessionController( fire(event) } + private fun connection(event: SessionControllerEvent.ConnectionChanged) { + if (event is SessionControllerEvent.ConnectionChanged.Hide || event is SessionControllerEvent.ConnectionChanged.ShowWarning) { + connDelay.cancel() + showConnection(event) + return + } + if (conn == event) { + connDelay.cancel() + return + } + connDelay.run(event, ::showConnection) + } + + private fun showConnection(event: SessionControllerEvent.ConnectionChanged) { + if (conn == event) return + if (conn == null && event is SessionControllerEvent.ConnectionChanged.Hide) { + conn = event + return + } + conn = event + fire(event) + } + + private fun syncConnection() { + connection(connectionState()) + } + + private fun connectionState(): SessionControllerEvent.ConnectionChanged { + val app = model.app + val workspace = model.workspace + + if (app.status == KiloAppStatusDto.ERROR) { + return SessionControllerEvent.ConnectionChanged.ShowError( + app.error ?: KiloBundle.message("session.connection.error.unknown"), + app.errors.toErrorText(), + ) + } + + if (workspace.status == KiloWorkspaceStatusDto.ERROR) { + return SessionControllerEvent.ConnectionChanged.ShowError( + workspace.error ?: KiloBundle.message("session.connection.error.unknown"), + null, + "workspace", + ) + } + + if (app.status == KiloAppStatusDto.READY && workspace.status == KiloWorkspaceStatusDto.READY && app.warnings.isNotEmpty()) { + return SessionControllerEvent.ConnectionChanged.ShowWarning( + summary(app.warnings.size), + app.warnings.toWarningText(), + ) + } + + if (app.status == KiloAppStatusDto.READY && workspace.status == KiloWorkspaceStatusDto.READY) { + return SessionControllerEvent.ConnectionChanged.Hide + } + + return SessionControllerEvent.ConnectionChanged.ShowConnecting + } + private fun status(): String = when (partType) { "reasoning" -> KiloBundle.message("session.status.thinking") "text" -> KiloBundle.message("session.status.writing") @@ -604,6 +695,9 @@ class SessionController( } override fun dispose() { + connDelay.dispose() + historyDelay.dispose() + recentsDelay.dispose() eventJob?.cancel() cs.cancel() } @@ -676,6 +770,47 @@ private fun matchesSession(event: ChatEventDto, id: String): Boolean = when (eve is ChatEventDto.TodoUpdated -> event.sessionID == id } +private fun summary(count: Int): String { + val base = KiloBundle.message("session.connection.warning.config") + if (count <= 1) return base + return "$base ($count)" +} + +private sealed interface RecentsState { + data object Idle : RecentsState + data class Loading(val id: Any = Any()) : RecentsState + data object Loaded : RecentsState +} + +private sealed interface HistoryState { + data object Idle : HistoryState + data class Loading(val id: Any = Any()) : HistoryState +} + +private fun List.toErrorText(): String? { + val out = mapNotNull { it.toDetailLine() } + if (out.isEmpty()) return null + return out.joinToString("\n") +} + +private fun List.toWarningText(): String? { + val out = mapNotNull { it.toDetailLine() } + if (out.isEmpty()) return null + return out.joinToString("\n\n") +} + +private fun LoadErrorDto.toDetailLine(): String? { + val detail = detail?.trim()?.ifEmpty { null } ?: return null + if (resource == "connection") return detail + return "$resource: $detail" +} + +private fun ConfigWarningDto.toDetailLine(): String { + val head = "$path: $message" + val tail = detail?.trim()?.ifEmpty { null } ?: return head + return "$head\n$tail" +} + private fun toPermission(dto: PermissionRequestDto): Permission { val ref = dto.tool?.let { ToolCallRef(it.messageID, it.callID) } val file = dto.metadata["file"] ?: dto.metadata["path"] diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionControllerEvent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionControllerEvent.kt index c11ff6ec532..121af83b5d8 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionControllerEvent.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionControllerEvent.kt @@ -33,6 +33,24 @@ sealed class SessionControllerEvent { override fun toString() = "ViewChanged session" } } + + sealed class ConnectionChanged : SessionControllerEvent() { + data object Hide : ConnectionChanged() { + override fun toString() = "ConnectionChanged hide" + } + + data object ShowConnecting : ConnectionChanged() { + override fun toString() = "ConnectionChanged connecting" + } + + data class ShowError(val summary: String, val detail: String?, val source: String = "app") : ConnectionChanged() { + override fun toString() = "ConnectionChanged error $source" + } + + data class ShowWarning(val summary: String, val detail: String?) : ConnectionChanged() { + override fun toString() = "ConnectionChanged warning" + } + } } /** diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index fc5b4f0c406..3253e1da3a4 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -4,6 +4,7 @@ session.connection.retry=Retry session.connection.warning.config=Configuration warnings session.empty.welcome=Kilo Code is an AI coding assistant. Ask it to build features, fix bugs, or explain your codebase. +session.empty.loading=Loading... session.empty.recent=RECENT session.empty.time.moments=Moments ago session.empty.time.minutes={0} min ago diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt index 5de11cff2c2..9e017aec555 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt @@ -30,6 +30,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { private lateinit var app: KiloAppService private val managers = mutableListOf() private val created = mutableListOf>() + private val loading = mutableListOf() override fun setUp() { super.setUp() @@ -65,11 +66,27 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { manager.newSession() val first = active(manager) + first.controller().prompt("hello") + settle() manager.newSession() val second = active(manager) assertNotSame(first, second) assertEquals(listOf("/test" to null, "/test" to null), created) + assertEquals(listOf(true, false), loading) + } + + fun `test new session on blank session keeps active component`() { + val manager = manager() + + manager.newSession() + val first = active(manager) + manager.newSession() + val second = active(manager) + + assertSame(first, second) + assertEquals(listOf("/test" to null), created) + assertEquals(listOf(true), loading) } fun `test opening same existing session reuses component`() { @@ -84,6 +101,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { assertSame(first, second) assertEquals(listOf("/test" to "ses_1", "/test" to null), created) + assertEquals(listOf(false, false), loading) } fun `test open session resolves historical workspace`() { @@ -92,6 +110,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { manager.openSession(session("ses_1", "/repo")) assertEquals(listOf("/repo" to "ses_1"), created) + assertEquals(listOf(false), loading) } fun `test dispose removes active component`() { @@ -108,9 +127,10 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { val manager = SessionSidePanelManager( project = project, root = workspace, - create = { project, workspace, owner, id -> + create = { project, workspace, owner, id, show -> created.add(workspace.directory to id) - SessionUi(project, workspace, sessions, app, scope, id = id, open = owner::openSession) + loading.add(show) + SessionUi(project, workspace, sessions, app, scope, id = id, loading = show, open = owner::openSession) }, resolve = { workspaces.workspace(it) }, ) @@ -120,6 +140,19 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { private fun active(manager: SessionSidePanelManager) = manager.component.getComponent(0) as JPanel + private fun JPanel.controller(): ai.kilocode.client.session.update.SessionController { + val field = SessionUi::class.java.getDeclaredField("controller") + field.isAccessible = true + return field.get(this) as ai.kilocode.client.session.update.SessionController + } + + private fun settle() = kotlinx.coroutines.runBlocking { + repeat(5) { + kotlinx.coroutines.delay(100) + com.intellij.util.ui.UIUtil.dispatchAllInvocationEvents() + } + } + private fun session(id: String) = session(id, "/test") private fun session(id: String, dir: String) = SessionDto( diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt index 4f7287c3161..bdb66f78bdd 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt @@ -48,7 +48,7 @@ class SessionUiFactoryTest : BasePlatformTestCase() { } fun `test factory creates blank session ui`() { - val ui = direct().create(project, workspace, FakeManager()) + val ui = direct().create(project, workspace, FakeManager(), null, true) assertNotNull(ui) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt index 6b18b94f604..3c4888d93ee 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt @@ -68,7 +68,7 @@ class SessionUiLayoutTest : BasePlatformTestCase() { workspaces = KiloWorkspaceService(scope, workspaceRpc) workspace = workspaces.workspace("/test") - ui = SessionUi(project, workspace, sessions, app, scope).apply { + ui = SessionUi(project, workspace, sessions, app, scope, displayMs = 0).apply { setSize(800, 600) } layout() @@ -173,10 +173,27 @@ class SessionUiLayoutTest : BasePlatformTestCase() { assertSame(find(ui), scroll.viewport.view) } + fun `test new session starts with loading body`() { + ui = SessionUi(project, workspace, sessions, app, scope, displayMs = 1_000).apply { + setSize(800, 600) + } + + assertFalse(find(ui).viewport.view is EmptySessionPanel) + } + + fun `test action-created new session starts blank`() { + ui = SessionUi(project, workspace, sessions, app, scope, displayMs = 1_000, loading = false).apply { + setSize(800, 600) + } + + assertFalse(find(ui).viewport.view is EmptySessionPanel) + assertFalse(find(ui).viewport.view is SessionMessageListPanel) + } + fun `test clicking recent session calls opener`() { val opened = mutableListOf() rpc.recent.add(session("ses_1")) - ui = SessionUi(project, workspace, sessions, app, scope, open = { opened.add(it.id) }).apply { + ui = SessionUi(project, workspace, sessions, app, scope, displayMs = 0, open = { opened.add(it.id) }).apply { setSize(800, 600) } @@ -190,7 +207,7 @@ class SessionUiLayoutTest : BasePlatformTestCase() { fun `test existing session id loads history and shows message body`() { rpc.history.add(MessageWithPartsDto(message("msg1"), emptyList())) - ui = SessionUi(project, workspace, sessions, app, scope, id = "ses_test").apply { + ui = SessionUi(project, workspace, sessions, app, scope, id = "ses_test", displayMs = 0).apply { setSize(800, 600) } settle() @@ -198,6 +215,38 @@ class SessionUiLayoutTest : BasePlatformTestCase() { assertSame(find(ui), find(ui).viewport.view) } + fun `test new session keeps loading body before recents delay`() { + rpc.recentGate = kotlinx.coroutines.CompletableDeferred() + ui = SessionUi(project, workspace, sessions, app, scope, displayMs = 1_000).apply { + setSize(800, 600) + } + + settleShort(100) + + assertFalse(find(ui).viewport.view is EmptySessionPanel) + } + + fun `test slow recents switch to loading body only after progress event`() { + rpc.recentGate = kotlinx.coroutines.CompletableDeferred() + rpc.recent.add(session("ses_1")) + ui = SessionUi(project, workspace, sessions, app, scope, displayMs = 50).apply { + setSize(800, 600) + } + + settleShort(20) + assertFalse(find(ui).viewport.view is EmptySessionPanel) + + settleShort(80) + assertFalse(find(ui).viewport.view is EmptySessionPanel) + + rpc.recentGate!!.complete(Unit) + settle() + + val panel = find(ui) + assertSame(panel, find(ui).viewport.view) + assertEquals(1, panel.recentCount()) + } + private fun layout() { ui.doLayout() val root = find(ui) @@ -213,11 +262,13 @@ class SessionUiLayoutTest : BasePlatformTestCase() { } } + private fun settleShort(ms: Long) = runBlocking { + delay(ms) + com.intellij.util.ui.UIUtil.dispatchAllInvocationEvents() + } + private fun showConnection() { - val m = controller().model - m.app = KiloAppStateDto(KiloAppStatusDto.CONNECTING) - m.workspace = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.PENDING) - find(ui).onEvent(SessionControllerEvent.AppChanged) + find(ui).onEvent(SessionControllerEvent.ConnectionChanged.ShowConnecting) } private inline fun find(root: java.awt.Container): T { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt index 39f8a0dbaf6..620e8fce34c 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt @@ -1,13 +1,11 @@ package ai.kilocode.client.session.ui import ai.kilocode.client.session.update.SessionController +import ai.kilocode.client.session.update.SessionControllerEvent import ai.kilocode.client.session.update.SessionControllerTestBase import ai.kilocode.rpc.dto.ConfigWarningDto 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.LoadErrorDto import java.awt.Dimension @Suppress("UnstableApiUsage") @@ -25,9 +23,7 @@ class ConnectionPanelTest : SessionControllerTestBase() { fun `test loading hides retry and details`() { edt { - controller.model.app = KiloAppStateDto(KiloAppStatusDto.CONNECTING) - controller.model.workspace = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.PENDING) - panel.onEvent(ai.kilocode.client.session.update.SessionControllerEvent.AppChanged) + panel.onEvent(SessionControllerEvent.ConnectionChanged.ShowConnecting) } assertTrue(panel.isVisible) @@ -40,15 +36,10 @@ class ConnectionPanelTest : SessionControllerTestBase() { fun `test app error starts collapsed and expands details`() { edt { - controller.model.app = KiloAppStateDto( - status = KiloAppStatusDto.ERROR, - error = "CLI startup failed", - errors = listOf( - LoadErrorDto(resource = "connection", detail = "stderr line"), - LoadErrorDto(resource = "config", detail = "HTTP 500: broken"), - ), - ) - panel.onEvent(ai.kilocode.client.session.update.SessionControllerEvent.AppChanged) + panel.onEvent(SessionControllerEvent.ConnectionChanged.ShowError( + "CLI startup failed", + "stderr line\nconfig: HTTP 500: broken", + )) } assertTrue(panel.isVisible) @@ -73,12 +64,7 @@ class ConnectionPanelTest : SessionControllerTestBase() { fun `test workspace error shows retry without details`() { edt { - controller.model.app = KiloAppStateDto(KiloAppStatusDto.READY) - controller.model.workspace = KiloWorkspaceStateDto( - status = KiloWorkspaceStatusDto.ERROR, - error = "Workspace failed", - ) - panel.onEvent(ai.kilocode.client.session.update.SessionControllerEvent.WorkspaceChanged) + panel.onEvent(SessionControllerEvent.ConnectionChanged.ShowError("Workspace failed", null)) } assertTrue(panel.isVisible) @@ -95,7 +81,7 @@ class ConnectionPanelTest : SessionControllerTestBase() { status = KiloAppStatusDto.ERROR, error = "CLI startup failed", ) - panel.onEvent(ai.kilocode.client.session.update.SessionControllerEvent.AppChanged) + panel.onEvent(SessionControllerEvent.ConnectionChanged.ShowError("CLI startup failed", null)) } edt { panel.clickRetry() } flush() @@ -105,18 +91,10 @@ class ConnectionPanelTest : SessionControllerTestBase() { fun `test ready warnings show collapsed banner with retry`() { edt { - controller.model.app = KiloAppStateDto( - status = KiloAppStatusDto.READY, - warnings = listOf( - ConfigWarningDto( - path = ".kilo/kilo.json", - message = "Invalid JSON", - detail = "CloseBraceExpected at line 11, column 1", - ) - ), - ) - controller.model.workspace = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.READY) - panel.onEvent(ai.kilocode.client.session.update.SessionControllerEvent.AppChanged) + panel.onEvent(SessionControllerEvent.ConnectionChanged.ShowWarning( + "Configuration warnings", + ".kilo/kilo.json: Invalid JSON\nCloseBraceExpected at line 11, column 1", + )) } assertTrue(panel.isVisible) @@ -143,8 +121,7 @@ class ConnectionPanelTest : SessionControllerTestBase() { status = KiloAppStatusDto.READY, warnings = listOf(ConfigWarningDto(path = ".kilo/kilo.json", message = "Invalid JSON")), ) - controller.model.workspace = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.READY) - panel.onEvent(ai.kilocode.client.session.update.SessionControllerEvent.AppChanged) + panel.onEvent(SessionControllerEvent.ConnectionChanged.ShowWarning("Configuration warnings", null)) } edt { panel.clickRetry() } flush() @@ -154,12 +131,7 @@ class ConnectionPanelTest : SessionControllerTestBase() { fun `test expanded details height is capped at ten lines`() { edt { - controller.model.app = KiloAppStateDto( - status = KiloAppStatusDto.ERROR, - error = "CLI startup failed", - errors = listOf(LoadErrorDto(resource = "connection", detail = lines(30))), - ) - panel.onEvent(ai.kilocode.client.session.update.SessionControllerEvent.AppChanged) + panel.onEvent(SessionControllerEvent.ConnectionChanged.ShowError("CLI startup failed", lines(30))) panel.size = Dimension(480, 1000) } @@ -169,5 +141,14 @@ class ConnectionPanelTest : SessionControllerTestBase() { assertTrue(panel.preferredSize.height <= panel.maxExpandedHeight()) } + fun `test raw app and workspace events do not render panel`() { + edt { + panel.onEvent(SessionControllerEvent.AppChanged) + panel.onEvent(SessionControllerEvent.WorkspaceChanged) + } + + assertFalse(panel.isVisible) + } + private fun lines(count: Int) = (1..count).joinToString("\n") { "line $it" } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ConnectionDelayTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ConnectionDelayTest.kt new file mode 100644 index 00000000000..db8902a06b6 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ConnectionDelayTest.kt @@ -0,0 +1,156 @@ +package ai.kilocode.client.session.update + +import ai.kilocode.rpc.dto.ConfigWarningDto +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.LoadErrorDto + +class ConnectionDelayTest : SessionControllerTestBase() { + + fun `test short connecting state does not fire connection banner`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller(displayMs = 100) + val events = collect(m) + flush() + events.clear() + + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.CONNECTING) + pause(25) + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + pause(150) + + assertFalse(events.any { it is SessionControllerEvent.ConnectionChanged.ShowConnecting }) + } + + fun `test persistent connecting state fires connection banner after delay`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller(displayMs = 50) + val events = collect(m) + flush() + events.clear() + + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.CONNECTING) + pause(20) + assertFalse(events.any { it is SessionControllerEvent.ConnectionChanged.ShowConnecting }) + + pause(80) + + assertEquals(1, events.count { it is SessionControllerEvent.ConnectionChanged.ShowConnecting }) + } + + fun `test short app error is suppressed`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller(displayMs = 100) + val events = collect(m) + flush() + events.clear() + + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.ERROR, error = "boom") + pause(25) + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + pause(150) + + assertFalse(events.any { it is SessionControllerEvent.ConnectionChanged.ShowError }) + } + + fun `test persistent app error fires latest error after delay`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller(displayMs = 50) + val events = collect(m) + flush() + events.clear() + + appRpc.state.value = KiloAppStateDto( + status = KiloAppStatusDto.ERROR, + error = "CLI startup failed", + errors = listOf( + LoadErrorDto(resource = "connection", detail = "stderr line"), + LoadErrorDto(resource = "config", detail = "HTTP 500"), + ), + ) + pause(80) + + val event = events.filterIsInstance().single() + assertEquals("CLI startup failed", event.summary) + assertEquals("stderr line\nconfig: HTTP 500", event.detail) + } + + fun `test changed error restarts delay and shows latest state`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller(displayMs = 100) + val events = collect(m) + flush() + events.clear() + + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.ERROR, error = "first") + pause(50) + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.ERROR, error = "second") + pause(150) + + val errors = events.filterIsInstance() + assertEquals(listOf("second"), errors.map { it.summary }) + } + + fun `test persistent workspace error is delayed`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller(displayMs = 50) + val events = collect(m) + flush() + events.clear() + + projectRpc.state.value = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.ERROR, error = "workspace failed") + pause(20) + assertFalse(events.any { it is SessionControllerEvent.ConnectionChanged.ShowError }) + + pause(80) + + val event = events.filterIsInstance().single() + assertEquals("workspace failed", event.summary) + assertNull(event.detail) + } + + fun `test ready hides visible delayed connection banner immediately`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller(displayMs = 50) + val events = collect(m) + flush() + events.clear() + + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.CONNECTING) + pause(80) + events.clear() + + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + pause(10) + + assertTrue(events.any { it is SessionControllerEvent.ConnectionChanged.Hide }) + } + + fun `test config warning remains immediate`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller(displayMs = 1_000) + val events = collect(m) + flush() + events.clear() + + appRpc.state.value = KiloAppStateDto( + status = KiloAppStatusDto.READY, + warnings = listOf(ConfigWarningDto(path = ".kilo/kilo.json", message = "Invalid JSON")), + ) + pause(10) + + val event = events.filterIsInstance().single() + assertEquals("Configuration warnings", event.summary) + assertEquals(".kilo/kilo.json: Invalid JSON", event.detail) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionControllerTestBase.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionControllerTestBase.kt index 0ed6891bf8f..b6c136b9531 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionControllerTestBase.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionControllerTestBase.kt @@ -10,8 +10,6 @@ import ai.kilocode.client.testing.FakeWorkspaceRpcApi import ai.kilocode.client.testing.FakeSessionRpcApi import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.app.Workspace -import ai.kilocode.client.session.update.SessionController -import ai.kilocode.client.session.update.SessionControllerEvent import ai.kilocode.rpc.dto.AgentDto import ai.kilocode.rpc.dto.AgentsDto import ai.kilocode.rpc.dto.ChatEventDto @@ -122,13 +120,20 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { // ------ Controller creation ------ - protected fun controller(id: String? = null) = controller(id, Long.MAX_VALUE) - - protected fun controller(id: String? = null, flushMs: Long): SessionController { - return controller(id, flushMs, true) + protected fun controller( + id: String? = null, + flushMs: Long = Long.MAX_VALUE, + displayMs: Long = Long.MAX_VALUE, + ): SessionController { + return controller(id, flushMs, true, displayMs) } - protected fun controller(id: String? = null, flushMs: Long, condense: Boolean): SessionController { + protected fun controller( + id: String? = null, + flushMs: Long, + condense: Boolean, + displayMs: Long = Long.MAX_VALUE, + ): SessionController { val root = Root() val m = SessionController( parent, @@ -139,7 +144,8 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { scope, root, flushMs, - condense + condense, + displayMs ) controllers.add(m) roots[m] = root @@ -198,6 +204,14 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { } } + protected fun pause(ms: Long) = runBlocking { + val tick = 10L + repeat((ms / tick).coerceAtLeast(1).toInt()) { + delay(tick) + edt { UIUtil.dispatchAllInvocationEvents() } + } + } + protected fun edt(block: () -> Unit) { ApplicationManager.getApplication().invokeAndWait(block) } @@ -231,7 +245,7 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { protected fun assertSession(expected: String, c: SessionController, show: Boolean = true) { assertEquals(expected.trimIndent().trim(), c.toString().trim()) - assertEquals(show, c.model.showMessages) + assertEquals(show, c.model.showSession) } protected fun assertControllerEvents(expected: String, events: List) { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ViewSwitchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ViewSwitchingTest.kt index 1eff29033fb..1b8b2684a81 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ViewSwitchingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ViewSwitchingTest.kt @@ -1,5 +1,7 @@ package ai.kilocode.client.session.update +import kotlinx.coroutines.CompletableDeferred + class ViewSwitchingTest : SessionControllerTestBase() { fun `test first prompt shows messages view`() { @@ -47,7 +49,6 @@ class ViewSwitchingTest : SessionControllerTestBase() { AppChanged WorkspaceChanged WorkspaceReady - ViewChanged progress ViewChanged recents=1 """, events) } @@ -65,14 +66,13 @@ class ViewSwitchingTest : SessionControllerTestBase() { AppChanged WorkspaceChanged WorkspaceReady - ViewChanged progress ViewChanged recents=0 """, events) } fun `test empty history transitions to recents`() { rpc.recent.add(session("ses_1")) - val m = controller("ses_test") + val m = controller("ses_test", displayMs = 1_000) val events = collect(m) flush() @@ -80,11 +80,82 @@ class ViewSwitchingTest : SessionControllerTestBase() { assertControllerEvents(""" AppChanged WorkspaceChanged - ViewChanged progress ViewChanged recents=1 """, events) } + fun `test slow recents show progress after delay then recents`() { + projectRpc.state.value = workspaceReady() + rpc.recent.add(session("ses_1")) + val gate = CompletableDeferred() + rpc.recentGate = gate + val m = controller(displayMs = 50) + val events = collect(m) + + pause(20) + assertTrue(rpc.recentCalls.contains("/test" to SessionController.RECENT_LIMIT)) + assertFalse(events.any { it is SessionControllerEvent.ViewChanged.ShowProgress }) + + pause(80) + assertTrue(events.any { it is SessionControllerEvent.ViewChanged.ShowProgress }) + + gate.complete(Unit) + flush() + + assertEquals( + """ + ViewChanged progress + ViewChanged recents=1 + """.trimIndent().trim(), + events.filterIsInstance().joinToString("\n"), + ) + } + + fun `test fast recents suppress progress`() { + projectRpc.state.value = workspaceReady() + rpc.recent.add(session("ses_1")) + val m = controller(displayMs = 1_000) + val events = collect(m) + + flush() + + assertTrue(rpc.recentCalls.contains("/test" to SessionController.RECENT_LIMIT)) + assertFalse(events.any { it is SessionControllerEvent.ViewChanged.ShowProgress }) + assertTrue(events.any { it is SessionControllerEvent.ViewChanged.ShowRecents }) + } + + fun `test failed fast recents suppress progress and show empty recents`() { + projectRpc.state.value = workspaceReady() + rpc.recentFailures = 1 + val m = controller(displayMs = 1_000) + val events = collect(m) + + flush() + + assertFalse(events.any { it is SessionControllerEvent.ViewChanged.ShowProgress }) + assertEquals(1, events.count { it is SessionControllerEvent.ViewChanged.ShowRecents }) + assertTrue(events.filterIsInstance().single().recents.isEmpty()) + } + + fun `test recents progress is canceled when messages view appears`() { + projectRpc.state.value = workspaceReady() + rpc.recent.add(session("ses_1")) + val gate = CompletableDeferred() + rpc.recentGate = gate + val m = controller(displayMs = 50) + val events = collect(m) + + pause(20) + edt { m.prompt("hello") } + pause(80) + gate.complete(Unit) + flush() + + assertTrue(events.any { it is SessionControllerEvent.ViewChanged.ShowSession }) + assertFalse(events.any { it is SessionControllerEvent.ViewChanged.ShowProgress }) + assertFalse(events.any { it is SessionControllerEvent.ViewChanged.ShowRecents }) + } + private fun session(id: String) = ai.kilocode.rpc.dto.SessionDto( id = id, projectID = "prj", diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/WorkspaceWatchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/WorkspaceWatchingTest.kt index 0fd38ca981e..cf711158cd1 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/WorkspaceWatchingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/WorkspaceWatchingTest.kt @@ -17,7 +17,6 @@ class WorkspaceWatchingTest : SessionControllerTestBase() { assertEquals("gpt-5", m.model.models[0].id) assertFalse(m.model.isReady()) assertControllerEvents(""" - ViewChanged progress ViewChanged recents=0 WorkspaceChanged WorkspaceReady diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt index 90a39cde34c..8759f4d913e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt @@ -14,6 +14,7 @@ import ai.kilocode.rpc.dto.SessionDto import ai.kilocode.rpc.dto.SessionListDto import ai.kilocode.rpc.dto.SessionStatusDto import ai.kilocode.rpc.dto.SessionTimeDto +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -45,6 +46,7 @@ class FakeSessionRpcApi : KiloSessionRpcApi { /** Recent sessions returned by [recent]. */ val recent = mutableListOf() var recentFailures = 0 + var recentGate: CompletableDeferred? = null /** Push chat events here; tests collect from [events]. */ val events = MutableSharedFlow(extraBufferCapacity = 64, replay = 64) @@ -90,6 +92,7 @@ class FakeSessionRpcApi : KiloSessionRpcApi { override suspend fun recent(directory: String, limit: Int): SessionListDto { assertNotEdt("recent") recentCalls.add(directory to limit) + recentGate?.await() if (recentFailures > 0) { recentFailures-- throw IllegalStateException("recent unavailable") From 37176027a94f74a890d9bcdabc041a7426f6b584 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 28 Apr 2026 16:18:54 -0400 Subject: [PATCH 12/16] refactor: consolidate JetBrains session delayed state --- .../client/session/update/DelayedState.kt | 69 +++-- .../session/update/SessionController.kt | 256 +++++++++++------- .../session/SessionSidePanelManagerTest.kt | 4 +- .../client/session/SessionUiFactoryTest.kt | 4 +- .../client/session/SessionUiLayoutTest.kt | 4 +- .../client/session/update/AppWatchingTest.kt | 24 ++ .../session/update/ConnectionDelayTest.kt | 69 +++++ .../client/session/update/DelayedStateTest.kt | 126 +++++++++ .../session/update/HistoryLoadingTest.kt | 1 - .../update/SessionControllerTestBase.kt | 18 +- .../session/update/ViewSwitchingTest.kt | 45 +++ 11 files changed, 491 insertions(+), 129 deletions(-) create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/DelayedStateTest.kt diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/DelayedState.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/DelayedState.kt index 99647496ffa..9dee0ea2acb 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/DelayedState.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/DelayedState.kt @@ -4,47 +4,56 @@ import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager import javax.swing.Timer -internal class DelayedState( +internal class DelayedState( private val ms: Long, - private val current: () -> T, ) : Disposable { - private var timer: Timer? = null - private var state: T? = null + private val tick = when { + ms <= 0 -> 1 + ms > Int.MAX_VALUE -> Int.MAX_VALUE + else -> ms.coerceAtMost(TICK_MS).toInt() + } + private val timer = Timer(tick) { flush() } + private val pending = mutableListOf>() @Volatile private var alive = true - fun run(state: T, action: (T) -> Unit) { + fun run(state: T, current: () -> T, action: (T) -> Unit) { edt { if (!alive) return@edt - timer?.stop() - this.state = state + val next = Pending(state, due(), current, action) + pending.add(next) if (ms <= 0) { - apply(state, action) + apply(next) return@edt } - timer = Timer(ms.coerceAtMost(Int.MAX_VALUE.toLong()).toInt()) { - apply(state, action) - }.apply { - isRepeats = false - start() - } + timer.start() } } fun cancel() { edt { - timer?.stop() - timer = null - state = null + pending.clear() } } - private fun apply(state: T, action: (T) -> Unit) { + private fun apply(item: Pending) { if (!alive) return - if (this.state != state) return - if (current() != state) return - this.state = null - timer = null - action(state) + if (!pending.remove(item)) return + if (item.current() != item.state) return + item.action(item.state) + } + + private fun flush() { + if (!alive) return + val now = System.currentTimeMillis() + for (item in pending.toList()) { + if (item.due > now) continue + apply(item) + } + } + + private fun due(): Long { + val now = System.currentTimeMillis() + return now + ms.coerceAtMost(Long.MAX_VALUE - now) } private fun edt(block: () -> Unit) { @@ -59,5 +68,19 @@ internal class DelayedState( override fun dispose() { alive = false cancel() + edt { + timer.stop() + } + } + + private data class Pending( + val state: T, + val due: Long, + val current: () -> T, + val action: (T) -> Unit, + ) + + private companion object { + const val TICK_MS = 25L } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt index 58950e52757..a8349a7c7cd 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt @@ -93,50 +93,18 @@ class SessionController( private var partType: String? = null private var tool: String? = null private var eventJob: Job? = null - private var history: HistoryState = HistoryState.Idle - private var recents: RecentsState = RecentsState.Idle - private var view: SessionControllerEvent.ViewChanged? = null - private var conn: SessionControllerEvent.ConnectionChanged? = null - private val connDelay = DelayedState(displayMs, ::connectionState) - private val historyDelay = DelayedState(displayMs) { history } - private val recentsDelay = DelayedState(displayMs) { recents } + private var historyState: HistoryState = HistoryState.Idle + private var recentsState: RecentsState = RecentsState.Idle + private var viewState: SessionControllerEvent.ViewChanged? = null + private var connectionState: SessionControllerEvent.ConnectionChanged? = null + private var connectionTargetState: SessionControllerEvent.ConnectionChanged? = null + private val delayedState = DelayedState(displayMs) val ready: Boolean get() = model.isReady() internal val blank: Boolean get() = sessionId == null && model.isEmpty() && !model.showSession - fun refreshRecents(force: Boolean = false) { - if (model.showSession) return - if (recents is RecentsState.Loading) return - if (recents is RecentsState.Loaded && !force) return - val state = RecentsState.Loading() - recents = state - recentsDelay.run(state) { - view(SessionControllerEvent.ViewChanged.ShowProgress) - } - cs.launch { - try { - val items = sessions.recent(directory, RECENT_LIMIT) - edt { - if (recents != state) return@edt - recentsDelay.cancel() - recents = RecentsState.Loaded - if (model.showSession) return@edt - view(SessionControllerEvent.ViewChanged.ShowRecents(items)) - } - } catch (e: Exception) { - LOG.warn("kind=session-recent dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) - edt { - if (recents != state) return@edt - recentsDelay.cancel() - recents = RecentsState.Loaded - if (model.showSession) return@edt - view(SessionControllerEvent.ViewChanged.ShowRecents(emptyList())) - } - } - } - } - fun openSession(session: SessionDto) { + assertEdt() open(session) } @@ -145,9 +113,25 @@ class SessionController( Disposer.register(parent) { listeners.remove(listener) } } - internal fun flushEvents() = updates.requestFlush(true) + internal fun snapshotState(): ControllerStateSnapshot { + assertEdt() + return ControllerStateSnapshot( + showSession = model.showSession, + viewState = viewState, + connectionState = connectionState, + connectionTargetState = connectionTargetState, + historyState = historyState.toString(), + recentsState = recentsState.toString(), + ) + } + + internal fun flushEvents() { + assertEdt() + updates.requestFlush(true) + } fun prompt(text: String) { + assertEdt() val sid = sessionId ?: "pending" LOG.debug { "${ChatLogSummary.sid(sid)} ${ChatLogSummary.prompt(text)} ${ChatLogSummary.dir(directory)}" } showMessages() @@ -155,7 +139,9 @@ class SessionController( try { val id = sessionId ?: run { val session = sessions.create(directory) - sessionId = session.id + runEdt { + sessionId = session.id + } val meta = if (LOG.isDebugEnabled) ChatLogSummary.dir(directory) else "kind=session" LOG.info("${ChatLogSummary.sid(session.id)} kind=session $meta created=true") subscribeEvents() @@ -174,6 +160,7 @@ class SessionController( } fun abort() { + assertEdt() LOG.debug { "${ChatLogSummary.sid(sessionId ?: "pending")} kind=abort" } val id = sessionId ?: return cs.launch { @@ -187,9 +174,12 @@ class SessionController( } fun retryConnection() { + assertEdt() LOG.debug { "${ChatLogSummary.sid(sessionId ?: "pending")} kind=connection-retry app=${model.app.status} workspace=${model.workspace.status}" } + setConnectionTargetState(SessionControllerEvent.ConnectionChanged.ShowConnecting) + setVisibleConnectionState(SessionControllerEvent.ConnectionChanged.ShowConnecting) // App retry policy is backend-owned and may escalate from lightweight refresh to restart. if (model.app.status != KiloAppStatusDto.READY || model.app.status == KiloAppStatusDto.ERROR) { app.retryAsync() @@ -206,8 +196,8 @@ class SessionController( } fun selectAgent(name: String) { + assertEdt() LOG.debug { "${ChatLogSummary.sid(sessionId ?: "pending")} kind=config agent=$name" } - model.agent = name cs.launch { try { sessions.updateConfig(directory, ConfigUpdateDto(agent = name)) @@ -215,12 +205,14 @@ class SessionController( LOG.warn("${ChatLogSummary.sid(sessionId ?: "pending")} kind=config agent=$name dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) } } - fire(SessionControllerEvent.WorkspaceReady) + fire(SessionControllerEvent.WorkspaceReady) { + model.agent = name + } } fun selectModel(provider: String, id: String) { + assertEdt() LOG.debug { "${ChatLogSummary.sid(sessionId ?: "pending")} kind=config model=$provider/$id" } - model.model = "$provider/$id" cs.launch { try { sessions.updateConfig(directory, ConfigUpdateDto(model = "$provider/$id")) @@ -228,12 +220,15 @@ class SessionController( LOG.warn("${ChatLogSummary.sid(sessionId ?: "pending")} kind=config model=$provider/$id dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) } } - fire(SessionControllerEvent.WorkspaceReady) + fire(SessionControllerEvent.WorkspaceReady) { + model.model = "$provider/$id" + } } // ------ permission / question resolution ------ fun replyPermission(requestId: String, reply: PermissionReplyDto, rules: PermissionAlwaysRulesDto? = null) { + assertEdt() LOG.debug { "${ChatLogSummary.sid(sessionId ?: "pending")} kind=permission rid=$requestId reply=${reply.reply}" } cs.launch { try { @@ -247,6 +242,7 @@ class SessionController( } fun replyQuestion(requestId: String, answers: QuestionReplyDto) { + assertEdt() LOG.debug { "${ChatLogSummary.sid(sessionId ?: "pending")} kind=question rid=$requestId answers=${answers.answers.size}" } cs.launch { try { @@ -259,6 +255,7 @@ class SessionController( } fun rejectQuestion(requestId: String) { + assertEdt() LOG.debug { "${ChatLogSummary.sid(sessionId ?: "pending")} kind=question rid=$requestId rejected=true" } cs.launch { try { @@ -287,7 +284,7 @@ class SessionController( fire(SessionControllerEvent.AppChanged) { model.app = state model.version = app.version - syncConnection() + syncConnectionState() } } } @@ -296,7 +293,7 @@ class SessionController( workspace.state.collect { state -> fire(SessionControllerEvent.WorkspaceChanged) { model.workspace = state - syncConnection() + syncConnectionState() if (state.status != KiloWorkspaceStatusDto.READY) return@fire @@ -335,16 +332,16 @@ class SessionController( cs.launch { val state = HistoryState.Loading() runEdt { - history = state + setHistoryState(state) } - historyDelay.run(state) { - if (!model.showSession) view(SessionControllerEvent.ViewChanged.ShowProgress) + delayedState.run(state, { historyState }) { + if (!model.showSession) setControllerViewState(SessionControllerEvent.ViewChanged.ShowProgress) } try { - val history = sessions.messages(id, directory) - LOG.debug { "${ChatLogSummary.sid(id)} ${ChatLogSummary.history(history)}" } + val items = sessions.messages(id, directory) + LOG.debug { "${ChatLogSummary.sid(id)} ${ChatLogSummary.history(items)}" } runEdt { - this@SessionController.model.loadHistory(history) + this@SessionController.model.loadHistory(items) } recoverPending(id) edt { @@ -359,9 +356,8 @@ class SessionController( edt { refreshRecents(force = true) } } finally { edt { - if (history != state) return@edt - historyDelay.cancel() - history = HistoryState.Idle + if (historyState != state) return@edt + setHistoryState(HistoryState.Idle) } updates.holdFlush(false) updates.requestFlush(true) @@ -575,50 +571,115 @@ class SessionController( } private fun showMessages() { + assertEdt() if (!model.showSession) { - model.showSession = true - historyDelay.cancel() - history = HistoryState.Idle - recentsDelay.cancel() - recents = RecentsState.Idle - view(SessionControllerEvent.ViewChanged.ShowSession) + setControllerViewState(SessionControllerEvent.ViewChanged.ShowSession) } } - private fun view(event: SessionControllerEvent.ViewChanged) { - if (view == event) return - view = event - fire(event) + private fun status(): String = when (partType) { + "reasoning" -> KiloBundle.message("session.status.thinking") + "text" -> KiloBundle.message("session.status.writing") + "tool" -> when (tool) { + "task" -> KiloBundle.message("session.status.delegating") + "todowrite", "todoread" -> KiloBundle.message("session.status.planning") + "read" -> KiloBundle.message("session.status.gathering") + "glob", "grep", "list" -> KiloBundle.message("session.status.searching.codebase") + "webfetch", "websearch", "codesearch" -> KiloBundle.message("session.status.searching.web") + "edit", "write" -> KiloBundle.message("session.status.editing") + "bash" -> KiloBundle.message("session.status.commands") + else -> KiloBundle.message("session.status.considering") + } + else -> KiloBundle.message("session.status.considering") } - private fun connection(event: SessionControllerEvent.ConnectionChanged) { + fun refreshRecents(force: Boolean = false) { + assertEdt() + if (model.showSession) return + if (recentsState is RecentsState.Loading) return + if (recentsState is RecentsState.Loaded && !force) return + val state = RecentsState.Loading() + setRecentSessionsState(state) + delayedState.run(state, { recentsState }) { + setControllerViewState(SessionControllerEvent.ViewChanged.ShowProgress) + } + cs.launch { + try { + val items = sessions.recent(directory, RECENT_LIMIT) + edt { + if (recentsState != state) return@edt + setRecentSessionsState(RecentsState.Loaded) + if (model.showSession) return@edt + setControllerViewState(SessionControllerEvent.ViewChanged.ShowRecents(items)) + } + } catch (e: Exception) { + LOG.warn("kind=session-recent dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) + edt { + if (recentsState != state) return@edt + setRecentSessionsState(RecentsState.Loaded) + if (model.showSession) return@edt + setControllerViewState(SessionControllerEvent.ViewChanged.ShowRecents(emptyList())) + } + } + } + } + + private fun setControllerViewState(event: SessionControllerEvent.ViewChanged) { + assertEdt() + if (viewState == event) return + fire(event) { + viewState = event + if (event is SessionControllerEvent.ViewChanged.ShowSession) { + model.showSession = true + setHistoryState(HistoryState.Idle) + setRecentSessionsState(RecentsState.Idle) + } + } + } + + private fun setConnectionTargetState(event: SessionControllerEvent.ConnectionChanged) { + assertEdt() + connectionTargetState = event + val state = event if (event is SessionControllerEvent.ConnectionChanged.Hide || event is SessionControllerEvent.ConnectionChanged.ShowWarning) { - connDelay.cancel() - showConnection(event) + setVisibleConnectionState(event) return } - if (conn == event) { - connDelay.cancel() + if (connectionState == event) { return } - connDelay.run(event, ::showConnection) + delayedState.run(event, { if (connectionTargetState == state) state else resolveConnectionState() }, ::setVisibleConnectionState) } - private fun showConnection(event: SessionControllerEvent.ConnectionChanged) { - if (conn == event) return - if (conn == null && event is SessionControllerEvent.ConnectionChanged.Hide) { - conn = event + private fun setVisibleConnectionState(event: SessionControllerEvent.ConnectionChanged) { + assertEdt() + if (connectionState == event) return + if (connectionState == null && event is SessionControllerEvent.ConnectionChanged.Hide) { + connectionState = event return } - conn = event - fire(event) + fire(event) { + connectionState = event + } + } + + private fun syncConnectionState() { + assertEdt() + setConnectionTargetState(resolveConnectionState()) } - private fun syncConnection() { - connection(connectionState()) + private fun setHistoryState(state: HistoryState) { + assertEdt() + historyState = state } - private fun connectionState(): SessionControllerEvent.ConnectionChanged { + private fun setRecentSessionsState(state: RecentsState) { + assertEdt() + recentsState = state + } + + private fun resolveConnectionState(): SessionControllerEvent.ConnectionChanged { + assertEdt() val app = model.app val workspace = model.workspace @@ -651,22 +712,6 @@ class SessionController( return SessionControllerEvent.ConnectionChanged.ShowConnecting } - private fun status(): String = when (partType) { - "reasoning" -> KiloBundle.message("session.status.thinking") - "text" -> KiloBundle.message("session.status.writing") - "tool" -> when (tool) { - "task" -> KiloBundle.message("session.status.delegating") - "todowrite", "todoread" -> KiloBundle.message("session.status.planning") - "read" -> KiloBundle.message("session.status.gathering") - "glob", "grep", "list" -> KiloBundle.message("session.status.searching.codebase") - "webfetch", "websearch", "codesearch" -> KiloBundle.message("session.status.searching.web") - "edit", "write" -> KiloBundle.message("session.status.editing") - "bash" -> KiloBundle.message("session.status.commands") - else -> KiloBundle.message("session.status.considering") - } - else -> KiloBundle.message("session.status.considering") - } - private fun fire(event: SessionControllerEvent, before: (() -> Unit)? = null) { LOG.debug { "session=$sessionId controller: $event" } val application = ApplicationManager.getApplication() @@ -681,6 +726,10 @@ class SessionController( } } + private fun assertEdt() { + check(ApplicationManager.getApplication().isDispatchThread) { "SessionController state must be accessed on EDT" } + } + private fun edt(block: () -> Unit) { ApplicationManager.getApplication().invokeLater(block) } @@ -695,9 +744,7 @@ class SessionController( } override fun dispose() { - connDelay.dispose() - historyDelay.dispose() - recentsDelay.dispose() + delayedState.dispose() eventJob?.cancel() cs.cancel() } @@ -787,6 +834,15 @@ private sealed interface HistoryState { data class Loading(val id: Any = Any()) : HistoryState } +internal data class ControllerStateSnapshot( + val showSession: Boolean, + val viewState: SessionControllerEvent.ViewChanged?, + val connectionState: SessionControllerEvent.ConnectionChanged?, + val connectionTargetState: SessionControllerEvent.ConnectionChanged?, + val historyState: String, + val recentsState: String, +) + private fun List.toErrorText(): String? { val out = mapNotNull { it.toDetailLine() } if (out.isEmpty()) return null diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt index 9e017aec555..d26ae6deeef 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt @@ -66,7 +66,9 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { manager.newSession() val first = active(manager) - first.controller().prompt("hello") + com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait { + first.controller().prompt("hello") + } settle() manager.newSession() val second = active(manager) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt index bdb66f78bdd..cad1b00915c 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt @@ -59,7 +59,9 @@ class SessionUiFactoryTest : BasePlatformTestCase() { val ui = SessionUi(project, workspace, sessions, app, scope, open = manager::openSession) val controller = controller(ui) - controller.openSession(rpc) + com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait { + controller.openSession(rpc) + } assertEquals(listOf("ses_1"), manager.opened) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt index 3c4888d93ee..4602afa07fa 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt @@ -166,7 +166,9 @@ class SessionUiLayoutTest : BasePlatformTestCase() { assertSame(empty, scroll.viewport.view) - controller().prompt("hello") + com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait { + controller().prompt("hello") + } layout() assertSame(scroll, find(ui).parent.parent) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/AppWatchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/AppWatchingTest.kt index b9b261be09f..efda159ca97 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/AppWatchingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/AppWatchingTest.kt @@ -28,18 +28,22 @@ class AppWatchingTest : SessionControllerTestBase() { fun `test retry connection uses app retry when app is failed`() { val m = controller() + val events = collect(m) appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.ERROR, error = "boom") flush() + events.clear() edt { m.retryConnection() } flush() assertEquals(1, appRpc.retries) assertEquals(0, projectRpc.reloads) + assertTrue(events.any { it is SessionControllerEvent.ConnectionChanged.ShowConnecting }) } fun `test retry connection reloads workspace when app ready and workspace failed`() { val m = controller() + val events = collect(m) appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) projectRpc.state.value = ai.kilocode.rpc.dto.KiloWorkspaceStateDto( status = KiloWorkspaceStatusDto.ERROR, @@ -47,25 +51,45 @@ class AppWatchingTest : SessionControllerTestBase() { ) flush() + events.clear() edt { m.retryConnection() } flush() assertEquals(0, appRpc.retries) assertEquals(1, projectRpc.reloads) + assertTrue(events.any { it is SessionControllerEvent.ConnectionChanged.ShowConnecting }) } fun `test retry connection uses app retry when app has warnings`() { val m = controller() + val events = collect(m) appRpc.state.value = KiloAppStateDto( status = KiloAppStatusDto.READY, warnings = listOf(ConfigWarningDto(path = ".kilo/kilo.json", message = "Invalid JSON")), ) flush() + events.clear() edt { m.retryConnection() } flush() assertEquals(1, appRpc.retries) assertEquals(0, projectRpc.reloads) + assertTrue(events.any { it is SessionControllerEvent.ConnectionChanged.ShowConnecting }) + } + + fun `test retry connection immediately updates connection state`() { + val m = controller() + val states = collectStates(m) + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.ERROR, error = "boom") + flush() + states.clear() + + edt { m.retryConnection() } + flush() + + val state = states.single { it.first is SessionControllerEvent.ConnectionChanged.ShowConnecting }.second + assertEquals(SessionControllerEvent.ConnectionChanged.ShowConnecting, state.connectionState) + assertEquals(SessionControllerEvent.ConnectionChanged.ShowConnecting, state.connectionTargetState) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ConnectionDelayTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ConnectionDelayTest.kt index db8902a06b6..a753eb1e640 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ConnectionDelayTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ConnectionDelayTest.kt @@ -6,6 +6,7 @@ import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto import ai.kilocode.rpc.dto.LoadErrorDto +import com.intellij.openapi.util.Disposer class ConnectionDelayTest : SessionControllerTestBase() { @@ -42,6 +43,21 @@ class ConnectionDelayTest : SessionControllerTestBase() { assertEquals(1, events.count { it is SessionControllerEvent.ConnectionChanged.ShowConnecting }) } + fun `test connecting event sees updated connection state on EDT`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller(displayMs = 50) + val states = collectStates(m) + flush() + states.clear() + + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.CONNECTING) + pause(80) + + val state = states.single { it.first is SessionControllerEvent.ConnectionChanged.ShowConnecting }.second + assertEquals(SessionControllerEvent.ConnectionChanged.ShowConnecting, state.connectionState) + } + fun `test short app error is suppressed`() { appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) projectRpc.state.value = workspaceReady() @@ -135,6 +151,25 @@ class ConnectionDelayTest : SessionControllerTestBase() { assertTrue(events.any { it is SessionControllerEvent.ConnectionChanged.Hide }) } + fun `test hide event sees updated connection state on EDT`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller(displayMs = 50) + val states = collectStates(m) + flush() + states.clear() + + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.CONNECTING) + pause(80) + states.clear() + + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + pause(10) + + val state = states.single { it.first is SessionControllerEvent.ConnectionChanged.Hide }.second + assertEquals(SessionControllerEvent.ConnectionChanged.Hide, state.connectionState) + } + fun `test config warning remains immediate`() { appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) projectRpc.state.value = workspaceReady() @@ -153,4 +188,38 @@ class ConnectionDelayTest : SessionControllerTestBase() { assertEquals("Configuration warnings", event.summary) assertEquals(".kilo/kilo.json: Invalid JSON", event.detail) } + + fun `test warning event sees updated connection state on EDT`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller(displayMs = 1_000) + val states = collectStates(m) + flush() + states.clear() + + appRpc.state.value = KiloAppStateDto( + status = KiloAppStatusDto.READY, + warnings = listOf(ConfigWarningDto(path = ".kilo/kilo.json", message = "Invalid JSON")), + ) + pause(10) + + val event = states.single { it.first is SessionControllerEvent.ConnectionChanged.ShowWarning } + assertEquals(event.first, event.second.connectionState) + } + + fun `test dispose suppresses pending delayed connection event`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller(displayMs = 50) + val events = collect(m) + flush() + events.clear() + + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.CONNECTING) + pause(20) + Disposer.dispose(m) + pause(100) + + assertFalse(events.any { it is SessionControllerEvent.ConnectionChanged.ShowConnecting }) + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/DelayedStateTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/DelayedStateTest.kt new file mode 100644 index 00000000000..d9b22f7e1fd --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/DelayedStateTest.kt @@ -0,0 +1,126 @@ +package ai.kilocode.client.session.update + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.util.ui.UIUtil +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking + +class DelayedStateTest : BasePlatformTestCase() { + private val states = mutableListOf() + + override fun tearDown() { + try { + states.forEach { Disposer.dispose(it) } + states.clear() + } finally { + super.tearDown() + } + } + + fun `test run applies action after delay when state still matches`() { + var state = "loading" + val out = mutableListOf() + val delay = delayed(30) + + delay.run("loading", { state }) { out.add(it) } + pause(120) + + assertEquals(listOf("loading"), out) + } + + fun `test run does not apply when current state changed`() { + var state = "loading" + val out = mutableListOf() + val delay = delayed(30) + + delay.run("loading", { state }) { out.add(it) } + edt { state = "loaded" } + pause(120) + + assertTrue(out.isEmpty()) + } + + fun `test multiple pending matching actions can run from shared timer`() { + var first = "loading" + var second = "connecting" + val out = mutableListOf() + val delay = delayed(30) + + delay.run("loading", { first }) { out.add("first:$it") } + delay.run("connecting", { second }) { out.add("second:$it") } + pause(120) + + assertEquals(listOf("first:loading", "second:connecting"), out) + } + + fun `test stale and matching pending actions are evaluated independently`() { + var state = "first" + val out = mutableListOf() + val delay = delayed(30) + + delay.run("first", { state }) { out.add(it) } + edt { state = "second" } + delay.run("second", { state }) { out.add(it) } + pause(120) + + assertEquals(listOf("second"), out) + } + + fun `test cancel suppresses pending actions`() { + var state = "loading" + val out = mutableListOf() + val delay = delayed(30) + + delay.run("loading", { state }) { out.add(it) } + delay.cancel() + pause(120) + + assertTrue(out.isEmpty()) + } + + fun `test dispose suppresses pending actions`() { + var state = "loading" + val out = mutableListOf() + val delay = delayed(30) + + delay.run("loading", { state }) { out.add(it) } + Disposer.dispose(delay) + states.remove(delay) + pause(120) + + assertTrue(out.isEmpty()) + } + + fun `test zero delay applies on EDT`() { + var state = "loading" + val out = mutableListOf() + val delay = delayed(0) + + delay.run("loading", { state }) { + out.add(ApplicationManager.getApplication().isDispatchThread) + } + pause(30) + + assertEquals(listOf(true), out) + } + + private fun delayed(ms: Long): DelayedState { + val state = DelayedState(ms) + states.add(state) + return state + } + + private fun pause(ms: Long) = runBlocking { + val tick = 10L + repeat((ms / tick).coerceAtLeast(1).toInt()) { + delay(tick) + edt { UIUtil.dispatchAllInvocationEvents() } + } + } + + private fun edt(block: () -> Unit) { + ApplicationManager.getApplication().invokeAndWait(block) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/HistoryLoadingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/HistoryLoadingTest.kt index 2ee8e5efc15..6f0cf34d24f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/HistoryLoadingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/HistoryLoadingTest.kt @@ -35,7 +35,6 @@ class HistoryLoadingTest : SessionControllerTestBase() { assertControllerEvents(""" AppChanged WorkspaceChanged - ViewChanged progress ViewChanged session """, events) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionControllerTestBase.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionControllerTestBase.kt index b6c136b9531..a3db4287546 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionControllerTestBase.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionControllerTestBase.kt @@ -174,12 +174,24 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { return events } + internal fun collectStates(m: SessionController): MutableList> { + val events = mutableListOf>() + val disposable = Disposer.newDisposable("state-listener") + Disposer.register(parent, disposable) + m.addListener(disposable) { event -> + assertTrue("Listener must be called on EDT", ApplicationManager.getApplication().isDispatchThread) + events.add(event to m.snapshotState()) + } + return events + } + /** Attach a listener that collects model events (messages, parts, phase). */ protected fun collectModelEvents(m: SessionController): MutableList { val events = mutableListOf() val disposable = Disposer.newDisposable("model-listener") Disposer.register(parent, disposable) m.model.addListener(disposable) { event -> + assertTrue("Model listener must be called on EDT", ApplicationManager.getApplication().isDispatchThread) events.add(event) } return events @@ -199,8 +211,10 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { protected fun flush() = runBlocking { repeat(5) { delay(100) - controllers.forEach { it.flushEvents() } - edt { UIUtil.dispatchAllInvocationEvents() } + edt { + controllers.forEach { it.flushEvents() } + UIUtil.dispatchAllInvocationEvents() + } } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ViewSwitchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ViewSwitchingTest.kt index 1b8b2684a81..1f5de63b397 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ViewSwitchingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ViewSwitchingTest.kt @@ -22,6 +22,20 @@ class ViewSwitchingTest : SessionControllerTestBase() { ) } + fun `test session event sees updated view state on EDT`() { + val m = controller() + val states = collectStates(m) + flush() + states.clear() + + edt { m.prompt("hello") } + flush() + + val state = states.single { it.first is SessionControllerEvent.ViewChanged.ShowSession }.second + assertTrue(state.showSession) + assertEquals(SessionControllerEvent.ViewChanged.ShowSession, state.viewState) + } + fun `test ViewChanged not fired twice`() { val m = controller() val events = collect(m) @@ -111,6 +125,24 @@ class ViewSwitchingTest : SessionControllerTestBase() { ) } + fun `test recents progress event sees loading state on EDT`() { + projectRpc.state.value = workspaceReady() + rpc.recent.add(session("ses_1")) + val gate = CompletableDeferred() + rpc.recentGate = gate + val m = controller(displayMs = 50) + val states = collectStates(m) + + pause(80) + + val state = states.single { it.first is SessionControllerEvent.ViewChanged.ShowProgress }.second + assertEquals(SessionControllerEvent.ViewChanged.ShowProgress, state.viewState) + assertTrue(state.recentsState.startsWith("Loading")) + + gate.complete(Unit) + flush() + } + fun `test fast recents suppress progress`() { projectRpc.state.value = workspaceReady() rpc.recent.add(session("ses_1")) @@ -124,6 +156,19 @@ class ViewSwitchingTest : SessionControllerTestBase() { assertTrue(events.any { it is SessionControllerEvent.ViewChanged.ShowRecents }) } + fun `test recents event sees loaded state on EDT`() { + projectRpc.state.value = workspaceReady() + rpc.recent.add(session("ses_1")) + val m = controller(displayMs = 1_000) + val states = collectStates(m) + + flush() + + val event = states.single { it.first is SessionControllerEvent.ViewChanged.ShowRecents } + assertEquals(event.first, event.second.viewState) + assertEquals("Loaded", event.second.recentsState) + } + fun `test failed fast recents suppress progress and show empty recents`() { projectRpc.state.value = workspaceReady() rpc.recentFailures = 1 From 8ff62a4f5d22dd87bd59134e373c0a5135181e98 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 28 Apr 2026 17:23:04 -0400 Subject: [PATCH 13/16] fix: adjust connection panel layout and rename retry action --- .../ai/kilocode/client/session/ui/ConnectionPanel.kt | 9 +++++++-- .../src/main/resources/messages/KiloBundle.properties | 2 +- .../ai/kilocode/client/session/ui/ConnectionPanelTest.kt | 5 +++++ 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt index c9e866887fc..06bd1d6905f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt @@ -37,7 +37,7 @@ class ConnectionPanel( } private val header = JPanel(BorderLayout()).apply { - border = JBUI.Borders.empty(4, 8) + border = JBUI.Borders.empty(4, 8, 0, 8) isOpaque = false } @@ -74,7 +74,7 @@ class ConnectionPanel( } private val scroll = JBScrollPane(details).apply { - border = JBUI.Borders.empty(0, 8, 4, 24) + border = JBUI.Borders.empty(0, 8, 4, 0) isOpaque = false viewport.isOpaque = false horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER @@ -89,6 +89,7 @@ class ConnectionPanel( Disposer.register(parent, this) isOpaque = true background = UIUtil.getPanelBackground() + border = JBUI.Borders.customLine(UIUtil.getBoundsColor(), 1, 0, 0, 0) left.add(toggle, BorderLayout.WEST) left.add(label, BorderLayout.CENTER) header.add(left, BorderLayout.CENTER) @@ -218,6 +219,8 @@ class ConnectionPanel( internal fun retryVisible() = retry.isVisible + internal fun retryText() = retry.text + internal fun detailsVisible() = scroll.isVisible internal fun toggleVisible() = toggle.isVisible @@ -241,6 +244,8 @@ class ConnectionPanel( internal fun clickRetry() = retry.doClick() + internal fun hasSeparator() = border != null + internal fun maxExpandedHeight() = header.preferredSize.height + details.getFontMetrics(details.font).height * DETAILS_LINES + scrollChrome() } diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index 3253e1da3a4..d4df97b4b5a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -1,6 +1,6 @@ session.connection.connecting=Loading... session.connection.error.unknown=Unknown error -session.connection.retry=Retry +session.connection.retry=Try again session.connection.warning.config=Configuration warnings session.empty.welcome=Kilo Code is an AI coding assistant. Ask it to build features, fix bugs, or explain your codebase. diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt index 620e8fce34c..87bdb68fa18 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt @@ -73,6 +73,7 @@ class ConnectionPanelTest : SessionControllerTestBase() { assertFalse(panel.detailsVisible()) assertEquals("", panel.detailsText()) assertTrue(panel.retryVisible()) + assertEquals("Try again", panel.retryText()) } fun `test retry click triggers app retry for app error`() { @@ -150,5 +151,9 @@ class ConnectionPanelTest : SessionControllerTestBase() { assertFalse(panel.isVisible) } + fun `test panel has top separator`() { + assertTrue(panel.hasSeparator()) + } + private fun lines(count: Int) = (1..count).joinToString("\n") { "line $it" } } From 51c74132f931104e27d1fd61489ff4e9f54c8d0e Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 28 Apr 2026 17:36:20 -0400 Subject: [PATCH 14/16] fix: use theme-safe JetBrains session icons and colors --- .../ai/kilocode/client/actions/NewSessionAction.kt | 4 ++-- .../kilocode/client/session/ui/ConnectionPanel.kt | 14 ++++++++++++-- .../client/session/ui/ConnectionPanelTest.kt | 3 +++ 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/NewSessionAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/NewSessionAction.kt index b6d958e6e88..dbe8aefcd6c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/NewSessionAction.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/NewSessionAction.kt @@ -2,15 +2,15 @@ package ai.kilocode.client.actions import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.SessionManager +import com.intellij.icons.AllIcons import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.project.DumbAware -import com.intellij.openapi.util.IconLoader class NewSessionAction : AnAction( KiloBundle.message("action.Kilo.NewSession.text"), KiloBundle.message("action.Kilo.NewSession.description"), - IconLoader.getIcon("/icons/plus.svg", NewSessionAction::class.java), + AllIcons.General.Add, ), DumbAware { override fun actionPerformed(e: AnActionEvent) { e.getData(SessionManager.KEY)?.newSession() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt index 06bd1d6905f..b943f9d8de4 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt @@ -7,6 +7,7 @@ import ai.kilocode.client.session.update.SessionControllerListener import com.intellij.icons.AllIcons import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer +import com.intellij.ui.JBColor import com.intellij.ui.components.ActionLink import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBScrollPane @@ -20,6 +21,7 @@ import java.awt.event.MouseAdapter import java.awt.event.MouseEvent import javax.swing.JPanel import javax.swing.ScrollPaneConstants +import javax.swing.UIManager class ConnectionPanel( parent: Disposable, @@ -28,6 +30,12 @@ class ConnectionPanel( companion object { private const val DETAILS_LINES = 10 + private val ERROR = JBColor.namedColor("Label.errorForeground", UIUtil.getErrorForeground()) + private val WARNING = JBColor.lazy { + UIManager.getColor("Component.warningFocusColor") + ?: UIManager.getColor("Label.warningForeground") + ?: UIUtil.getContextHelpForeground() + } } private val click = object : MouseAdapter() { @@ -131,7 +139,7 @@ class ConnectionPanel( } private fun showError(text: String, detail: String?) { - label.foreground = UIUtil.getErrorForeground() + label.foreground = ERROR label.text = text retry.isVisible = true this.detail = detail?.takeIf { it.isNotBlank() } @@ -141,7 +149,7 @@ class ConnectionPanel( } private fun showWarning(text: String, detail: String?) { - label.foreground = UIUtil.getContextHelpForeground() + label.foreground = WARNING label.text = text retry.isVisible = true this.detail = detail?.takeIf { it.isNotBlank() } @@ -215,6 +223,8 @@ class ConnectionPanel( internal fun summaryText() = label.text + internal fun summaryColor() = label.foreground + internal fun detailsText() = details.text internal fun retryVisible() = retry.isVisible diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt index 87bdb68fa18..3d29188009f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt @@ -6,6 +6,7 @@ import ai.kilocode.client.session.update.SessionControllerTestBase import ai.kilocode.rpc.dto.ConfigWarningDto import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.KiloAppStatusDto +import com.intellij.util.ui.UIUtil import java.awt.Dimension @Suppress("UnstableApiUsage") @@ -44,6 +45,7 @@ class ConnectionPanelTest : SessionControllerTestBase() { assertTrue(panel.isVisible) assertEquals("CLI startup failed", panel.summaryText()) + assertEquals(UIUtil.getErrorForeground(), panel.summaryColor()) assertTrue(panel.toggleVisible()) assertFalse(panel.toggleExpanded()) assertFalse(panel.detailsVisible()) @@ -100,6 +102,7 @@ class ConnectionPanelTest : SessionControllerTestBase() { assertTrue(panel.isVisible) assertEquals("Configuration warnings", panel.summaryText()) + assertNotSame(UIUtil.getContextHelpForeground(), panel.summaryColor()) assertTrue(panel.toggleVisible()) assertFalse(panel.toggleExpanded()) assertFalse(panel.detailsVisible()) From 382924d3983b116b0fc3ce3a6dd2549eae39bd1f Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 28 Apr 2026 17:52:28 -0400 Subject: [PATCH 15/16] fix: address JetBrains session review feedback --- .../backend/app/KiloBackendAppService.kt | 4 ++ .../client/session/SessionSidePanelManager.kt | 18 +++++++++ .../ai/kilocode/client/session/SessionUi.kt | 2 + .../client/session/update/DelayedState.kt | 4 ++ .../session/update/SessionController.kt | 1 + .../session/SessionSidePanelManagerTest.kt | 39 ++++++++++++++++++- .../client/session/update/DelayedStateTest.kt | 22 +++++++++++ 7 files changed, 88 insertions(+), 2 deletions(-) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt index 59b7285db2b..8f8e5c36754 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt @@ -390,8 +390,12 @@ class KiloBackendAppService private constructor( private suspend fun refreshConfigState() { val current = _appState.value as? KiloAppState.Ready ?: return + val connection = connection.state.value as? ConnectionState.Connected ?: return val cfg = fetchConfig().value ?: return val warns = fetchWarnings() + val state = _appState.value + if (state !is KiloAppState.Ready || state.data !== current.data) return + if (this.connection.state.value != connection) return config = cfg setAppReady( current.data.copy( diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt index 3c0e3f741e7..afc5d989606 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt @@ -33,10 +33,12 @@ class SessionSidePanelManager( override fun newSession() { val active = current if (active?.blank == true) return + register(active) show(create(project, root, this, null, active == null)) } override fun openSession(session: SessionDto) { + register(current) val ui = opened.getOrPut(session.id) { create(project, resolve(session.directory), this, session.id, false).also { all.add(it) @@ -48,6 +50,7 @@ class SessionSidePanelManager( private fun show(ui: SessionUi) { all.add(ui) if (current === ui) return + release(current) component.removeAll() current = ui component.add(ui, BorderLayout.CENTER) @@ -55,6 +58,21 @@ class SessionSidePanelManager( component.repaint() } + private fun register(ui: SessionUi?) { + val id = ui?.id ?: return + opened.putIfAbsent(id, ui) + } + + private fun release(ui: SessionUi?) { + if (ui == null) return + if (ui.id != null) { + register(ui) + return + } + all.remove(ui) + Disposer.dispose(ui) + } + override fun dispose() { val items = all.toList() opened.clear() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 3c1fa7cd473..7fb382d00b9 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -120,6 +120,8 @@ class SessionUi private constructor( internal val blank: Boolean get() = controller.blank + internal val id: String? get() = controller.id + private fun buildUi() { root = SessionRootPanel() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/DelayedState.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/DelayedState.kt index 9dee0ea2acb..2dba541f6b1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/DelayedState.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/DelayedState.kt @@ -32,9 +32,12 @@ internal class DelayedState( fun cancel() { edt { pending.clear() + timer.stop() } } + internal fun active() = timer.isRunning + private fun apply(item: Pending) { if (!alive) return if (!pending.remove(item)) return @@ -49,6 +52,7 @@ internal class DelayedState( if (item.due > now) continue apply(item) } + if (pending.isEmpty()) timer.stop() } private fun due(): Long { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt index a8349a7c7cd..3a3df476d87 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt @@ -102,6 +102,7 @@ class SessionController( val ready: Boolean get() = model.isReady() internal val blank: Boolean get() = sessionId == null && model.isEmpty() && !model.showSession + internal val id: String? get() = sessionId fun openSession(session: SessionDto) { assertEdt() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt index d26ae6deeef..e2525228094 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt @@ -24,6 +24,7 @@ import javax.swing.JPanel @Suppress("UnstableApiUsage") class SessionSidePanelManagerTest : BasePlatformTestCase() { private lateinit var scope: CoroutineScope + private lateinit var rpc: FakeSessionRpcApi private lateinit var workspaces: KiloWorkspaceService private lateinit var workspace: Workspace private lateinit var sessions: KiloSessionService @@ -31,11 +32,13 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { private val managers = mutableListOf() private val created = mutableListOf>() private val loading = mutableListOf() + private val ui = mutableListOf() override fun setUp() { super.setUp() scope = CoroutineScope(SupervisorJob()) - sessions = KiloSessionService(project, scope, FakeSessionRpcApi()) + rpc = FakeSessionRpcApi() + sessions = KiloSessionService(project, scope, rpc) app = KiloAppService(scope, FakeAppRpcApi().also { it.state.value = KiloAppStateDto(KiloAppStatusDto.READY) }) @@ -106,6 +109,35 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { assertEquals(listOf(false, false), loading) } + fun `test prompted blank session is reused from recents`() { + val manager = manager() + manager.newSession() + val first = active(manager) + + com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait { + first.controller().prompt("hello") + } + settle() + manager.newSession() + manager.openSession(session("ses_test")) + val second = active(manager) + + assertSame(first, second) + assertEquals(1, rpc.creates) + assertEquals(listOf("/test" to null, "/test" to null), created) + } + + fun `test anonymous blank session is disposed when replaced`() { + val manager = manager() + manager.newSession() + val first = active(manager) + + manager.openSession(session("ses_1")) + + assertNotSame(first, active(manager)) + assertFalse(ui.contains(first)) + } + fun `test open session resolves historical workspace`() { val manager = manager() @@ -132,7 +164,10 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { create = { project, workspace, owner, id, show -> created.add(workspace.directory to id) loading.add(show) - SessionUi(project, workspace, sessions, app, scope, id = id, loading = show, open = owner::openSession) + SessionUi(project, workspace, sessions, app, scope, id = id, loading = show, open = owner::openSession).also { + ui.add(it) + Disposer.register(it) { ui.remove(it) } + } }, resolve = { workspaces.workspace(it) }, ) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/DelayedStateTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/DelayedStateTest.kt index d9b22f7e1fd..8f6e5cfc3e0 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/DelayedStateTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/DelayedStateTest.kt @@ -80,6 +80,28 @@ class DelayedStateTest : BasePlatformTestCase() { assertTrue(out.isEmpty()) } + fun `test cancel stops timer`() { + var state = "loading" + val delay = delayed(30) + + delay.run("loading", { state }) {} + pause(10) + delay.cancel() + pause(10) + + assertFalse(delay.active()) + } + + fun `test timer stops after pending actions drain`() { + var state = "loading" + val delay = delayed(30) + + delay.run("loading", { state }) {} + pause(120) + + assertFalse(delay.active()) + } + fun `test dispose suppresses pending actions`() { var state = "loading" val out = mutableListOf() From 4a08f618d7eac596fe8ab75d2112fa9ced9b03f3 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 28 Apr 2026 18:33:04 -0400 Subject: [PATCH 16/16] fix: show JetBrains workspace error details --- .../backend/app/KiloBackendAppService.kt | 2 +- .../backend/rpc/KiloWorkspaceRpcApiImpl.kt | 9 + .../backend/workspace/KiloBackendWorkspace.kt | 183 +++++++++++------- .../workspace/KiloBackendWorkspaceManager.kt | 12 +- .../backend/workspace/KiloWorkspaceState.kt | 4 +- .../workspace/KiloBackendWorkspaceTest.kt | 17 ++ .../client/session/ui/ConnectionPanel.kt | 4 +- .../session/update/SessionController.kt | 8 +- .../resources/messages/KiloBundle.properties | 2 + .../client/session/ui/ConnectionPanelTest.kt | 1 + .../session/update/ConnectionDelayTest.kt | 15 +- .../kilocode/rpc/dto/KiloWorkspaceStateDto.kt | 1 + 12 files changed, 177 insertions(+), 81 deletions(-) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt index 8f8e5c36754..7d806d678d9 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt @@ -287,7 +287,7 @@ class KiloBackendAppService private constructor( notifications = notifs sessions.start(connection.api!!, connection.apiClient!!, connection.port, connection.events) chat.start(connection.apiClient!!, connection.port, connection.events) - workspaces.start(connection.api!!, connection.events) + workspaces.start(connection.api!!, connection.apiClient!!, connection.port, connection.events) setAppReady( AppData( profile = prof, diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt index 171a2e47d78..f8159030df2 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt @@ -4,6 +4,7 @@ package ai.kilocode.backend.rpc import ai.kilocode.backend.app.KiloAppState import ai.kilocode.backend.app.KiloBackendAppService +import ai.kilocode.backend.app.LoadError import ai.kilocode.backend.workspace.AgentData import ai.kilocode.backend.workspace.AgentInfo import ai.kilocode.backend.workspace.CommandInfo @@ -21,6 +22,7 @@ import ai.kilocode.rpc.dto.CommandDto import ai.kilocode.rpc.dto.KiloWorkspaceLoadProgressDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import ai.kilocode.rpc.dto.LoadErrorDto import ai.kilocode.rpc.dto.ModelDto import ai.kilocode.rpc.dto.ProviderDto import ai.kilocode.rpc.dto.ProvidersDto @@ -99,9 +101,16 @@ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi { is KiloWorkspaceState.Error -> KiloWorkspaceStateDto( status = KiloWorkspaceStatusDto.ERROR, error = state.message, + errors = state.errors.map(::error), ) } + private fun error(e: LoadError) = LoadErrorDto( + resource = e.resource, + status = e.status, + detail = e.detail, + ) + private fun progress(p: KiloWorkspaceLoadProgress) = KiloWorkspaceLoadProgressDto( providers = p.providers, agents = p.agents, diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt index 460bc9b4fe0..acbd32856a3 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt @@ -1,12 +1,20 @@ package ai.kilocode.backend.workspace import ai.kilocode.backend.app.KiloBackendSessionManager +import ai.kilocode.backend.app.LoadError import ai.kilocode.backend.app.SseEvent import ai.kilocode.log.KiloLog import ai.kilocode.jetbrains.api.client.DefaultApi import ai.kilocode.jetbrains.api.model.Agent import ai.kilocode.rpc.dto.SessionDto import ai.kilocode.rpc.dto.SessionListDto +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -18,6 +26,8 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import okhttp3.OkHttpClient +import okhttp3.Request import java.util.concurrent.atomic.AtomicReference /** @@ -36,6 +46,8 @@ class KiloBackendWorkspace( val directory: String, private val cs: CoroutineScope, private val api: DefaultApi, + private val http: OkHttpClient, + private val port: Int, private val events: SharedFlow, private val sessions: KiloBackendSessionManager, private val log: KiloLog, @@ -43,6 +55,7 @@ class KiloBackendWorkspace( companion object { private const val MAX_RETRIES = 3 private const val RETRY_DELAY_MS = 1000L + private val json = Json { ignoreUnknownKeys = true } } private val _state = MutableStateFlow(KiloWorkspaceState.Pending) @@ -66,52 +79,56 @@ class KiloBackendWorkspace( var ag: AgentData? = null var cmd: List? = null var sk: List? = null - val errors = mutableListOf() + val errors = mutableListOf() try { coroutineScope { launch { val result = fetchWithRetry("providers") { fetchProviders() } - if (result != null) { - prov = result + if (result.value != null) { + prov = result.value progress.updateAndGet { it.copy(providers = true) } .also { _state.value = KiloWorkspaceState.Loading(it) } } else { - synchronized(errors) { errors.add("providers") } - throw LoadFailure("providers") + val err = result.error ?: LoadError(resource = "providers") + synchronized(errors) { errors.add(err) } + throw LoadFailure(err) } } launch { val result = fetchWithRetry("agents") { fetchAgents() } - if (result != null) { - ag = result + if (result.value != null) { + ag = result.value progress.updateAndGet { it.copy(agents = true) } .also { _state.value = KiloWorkspaceState.Loading(it) } } else { - synchronized(errors) { errors.add("agents") } - throw LoadFailure("agents") + val err = result.error ?: LoadError(resource = "agents") + synchronized(errors) { errors.add(err) } + throw LoadFailure(err) } } launch { val result = fetchWithRetry("commands") { fetchCommands() } - if (result != null) { - cmd = result + if (result.value != null) { + cmd = result.value progress.updateAndGet { it.copy(commands = true) } .also { _state.value = KiloWorkspaceState.Loading(it) } } else { - synchronized(errors) { errors.add("commands") } - throw LoadFailure("commands") + val err = result.error ?: LoadError(resource = "commands") + synchronized(errors) { errors.add(err) } + throw LoadFailure(err) } } launch { val result = fetchWithRetry("skills") { fetchSkills() } - if (result != null) { - sk = result + if (result.value != null) { + sk = result.value progress.updateAndGet { it.copy(skills = true) } .also { _state.value = KiloWorkspaceState.Loading(it) } } else { - synchronized(errors) { errors.add("skills") } - throw LoadFailure("skills") + val err = result.error ?: LoadError(resource = "skills") + synchronized(errors) { errors.add(err) } + throw LoadFailure(err) } } } @@ -129,7 +146,9 @@ class KiloBackendWorkspace( throw e } catch (e: Exception) { log.warn("Workspace data load failed for $directory: ${e.message}") - setWorkspaceError("Failed to load: ${synchronized(errors) { errors.joinToString() }}") + val items = synchronized(errors) { errors.toList() } + val names = items.joinToString { it.resource } + setWorkspaceError("Failed to load: $names", items) } } } @@ -189,79 +208,56 @@ class KiloBackendWorkspace( // ------ fetch methods ------ - private fun fetchProviders(): ProviderData? = + private fun fetchProviders(): FetchResult = try { - val response = api.providerList(directory = directory) - ProviderData( - providers = response.all.map { p -> - ProviderInfo( - id = p.id, - name = p.name, - source = p.source.value, - models = p.models.mapValues { (_, m) -> - ModelInfo( - id = m.id, - name = m.name, - attachment = m.capabilities.attachment, - reasoning = m.capabilities.reasoning, - temperature = m.capabilities.temperature, - toolCall = m.capabilities.toolcall, - free = m.isFree ?: false, - status = m.status.value, - ) - }, - ) - }, - connected = response.connected, - defaults = response.default, - ) + FetchResult.ok(parseProviders(fetch("/provider?directory=${encode(directory)}"))) } catch (e: Exception) { log.warn("Providers fetch failed: ${e.message}", e) - null + FetchResult.fail("providers", e) } - private fun fetchAgents(): AgentData? = + private fun fetchAgents(): FetchResult = try { val response = api.appAgents(directory = directory) val mapped = response.map(::mapAgent) val visible = response.filter { it.mode != Agent.Mode.SUBAGENT && it.hidden != true } - AgentData( + FetchResult.ok(AgentData( agents = visible.map(::mapAgent), all = mapped, default = visible.firstOrNull()?.name ?: "code", - ) + )) } catch (e: Exception) { log.warn("Agents fetch failed: ${e.message}", e) - null + FetchResult.fail("agents", e) } - private fun fetchCommands(): List? = + private fun fetchCommands(): FetchResult> = try { - api.commandList(directory = directory).map { c -> + FetchResult.ok(api.commandList(directory = directory).map { c -> CommandInfo( name = c.name, description = c.description, source = c.source?.value, hints = c.hints, ) - } + }) } catch (e: Exception) { log.warn("Commands fetch failed: ${e.message}", e) - null + FetchResult.fail("commands", e) } - private fun fetchSkills(): List? = + private fun fetchSkills(): FetchResult> = try { - api.appSkills(directory = directory).map { s -> + FetchResult.ok(api.appSkills(directory = directory).map { s -> SkillInfo( name = s.name, description = s.description, location = s.location, ) - } + }) } catch (e: Exception) { log.warn("Skills fetch failed: ${e.message}", e) - null + FetchResult.fail("skills", e) } // ------ helpers ------ @@ -277,26 +273,79 @@ class KiloBackendWorkspace( deprecated = a.deprecated, ) + private fun parseProviders(raw: String): ProviderData { + val obj = json.parseToJsonElement(raw).jsonObject + return ProviderData( + providers = obj["all"]?.jsonArray?.map { provider(it.jsonObject) } ?: emptyList(), + connected = obj["connected"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList(), + defaults = obj["default"]?.jsonObject?.mapValues { (_, value) -> value.jsonPrimitive.content } ?: emptyMap(), + ) + } + + private fun provider(obj: JsonObject) = ProviderInfo( + id = obj.str("id") ?: "", + name = obj.str("name") ?: "", + source = obj.str("source"), + models = obj["models"]?.jsonObject?.mapValues { (id, value) -> model(id, value.jsonObject) } ?: emptyMap(), + ) + + private fun model(id: String, obj: JsonObject): ModelInfo { + val cap = obj["capabilities"]?.jsonObject + return ModelInfo( + id = obj.str("id") ?: id, + name = obj.str("name") ?: id, + attachment = cap.bool("attachment"), + reasoning = cap.bool("reasoning"), + temperature = cap.bool("temperature"), + toolCall = cap.bool("toolcall"), + free = obj.bool("isFree"), + status = obj.str("status"), + ) + } + + private fun fetch(path: String): String { + val request = Request.Builder().url("http://localhost:$port$path").get().build() + http.newCall(request).execute().use { response -> + val raw = response.body?.string().orEmpty() + if (!response.isSuccessful) throw RuntimeException("HTTP ${response.code}: $raw") + return raw + } + } + private suspend fun fetchWithRetry( name: String, - block: () -> T?, - ): T? { + block: () -> FetchResult, + ): FetchResult { + var last = FetchResult.fail(name) repeat(MAX_RETRIES) { attempt -> val result = block() - if (result != null) return result - if (attempt < MAX_RETRIES - 1) { - log.warn("$name: attempt ${attempt + 1}/$MAX_RETRIES failed — retrying in ${RETRY_DELAY_MS}ms") - delay(RETRY_DELAY_MS) - } + if (result.value != null) return result + last = result + if (attempt < MAX_RETRIES - 1) { + log.warn("$name: attempt ${attempt + 1}/$MAX_RETRIES failed — retrying in ${RETRY_DELAY_MS}ms") + delay(RETRY_DELAY_MS) + } } log.error("$name: all $MAX_RETRIES attempts failed") - return null + return last } - private fun setWorkspaceError(message: String) { - _state.value = KiloWorkspaceState.Error(message) + private fun setWorkspaceError(message: String, errors: List) { + _state.value = KiloWorkspaceState.Error(message, errors) log.warn("Workspace error [$directory]: $message") } - private class LoadFailure(resource: String) : Exception("Failed to load $resource") + private data class FetchResult(val value: T?, val error: LoadError?) { + companion object { + fun ok(value: T) = FetchResult(value, null) + fun fail(resource: String, e: Exception? = null) = FetchResult(null, LoadError(resource, detail = e?.message)) + } + } + + private class LoadFailure(val error: LoadError) : Exception("Failed to load ${error.resource}") + } + +private fun encode(value: String) = java.net.URLEncoder.encode(value, Charsets.UTF_8) +private fun JsonObject.str(key: String) = this[key]?.jsonPrimitive?.contentOrNull +private fun JsonObject?.bool(key: String) = this?.get(key)?.jsonPrimitive?.booleanOrNull ?: false diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceManager.kt index 2af74bd151e..93ec913349d 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceManager.kt @@ -7,6 +7,7 @@ import ai.kilocode.log.KiloLog import ai.kilocode.jetbrains.api.client.DefaultApi import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.SharedFlow +import okhttp3.OkHttpClient import java.util.concurrent.ConcurrentHashMap /** @@ -28,6 +29,8 @@ class KiloBackendWorkspaceManager( private val workspaces = ConcurrentHashMap() private var api: DefaultApi? = null + private var http: OkHttpClient? = null + private var port = 0 private var events: SharedFlow? = null /** @@ -35,9 +38,11 @@ class KiloBackendWorkspaceManager( * Called by [KiloBackendAppService] after [KiloAppState.Ready]. * Clears any stale workspaces from a previous connection. */ - fun start(api: DefaultApi, events: SharedFlow) { + fun start(api: DefaultApi, http: OkHttpClient, port: Int, events: SharedFlow) { stop() this.api = api + this.http = http + this.port = port this.events = events log.info("Workspace manager started") } @@ -49,6 +54,8 @@ class KiloBackendWorkspaceManager( workspaces.values.forEach { it.stop() } workspaces.clear() api = null + http = null + port = 0 events = null log.info("Workspace manager stopped") } @@ -59,10 +66,11 @@ class KiloBackendWorkspaceManager( */ fun get(dir: String): KiloBackendWorkspace { val client = api ?: throw IllegalStateException("Workspace manager not started") + val http = http ?: throw IllegalStateException("Workspace manager not started") val ev = events!! return workspaces.computeIfAbsent(dir) { d -> log.info("Creating workspace for $d") - KiloBackendWorkspace(d, cs, client, ev, sessions, log).also { it.load() } + KiloBackendWorkspace(d, cs, client, http, port, ev, sessions, log).also { it.load() } } } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloWorkspaceState.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloWorkspaceState.kt index 7a4ad30b029..0006a331750 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloWorkspaceState.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloWorkspaceState.kt @@ -1,5 +1,7 @@ package ai.kilocode.backend.workspace +import ai.kilocode.backend.app.LoadError + /** * Workspace data lifecycle state, combining connection readiness * with directory-scoped data loading progress. @@ -17,7 +19,7 @@ sealed class KiloWorkspaceState { val commands: List, val skills: List, ) : KiloWorkspaceState() - data class Error(val message: String) : KiloWorkspaceState() + data class Error(val message: String, val errors: List = emptyList()) : KiloWorkspaceState() } /** diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt index 9da0fa6cd88..a5b177710e0 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt @@ -179,9 +179,25 @@ class KiloBackendWorkspaceTest { val err = ws.state.value as KiloWorkspaceState.Error assertTrue(err.message.contains("providers")) + assertTrue(err.errors.any { it.resource == "providers" }) assertTrue(log.messages.any { it.contains("Workspace error [/test/project]: Failed to load:") && it.contains("providers") }) } + @Test + fun `providers decode failure includes detail`() = runBlocking { + mock.providers = """{"all":[false],"default":{},"connected":[]}""" + val app = setup() + val ws = ready(app) + + withTimeout(15_000) { + ws.state.first { it is KiloWorkspaceState.Error } + } + + val err = ws.state.value as KiloWorkspaceState.Error + val detail = err.errors.single { it.resource == "providers" }.detail + assertTrue(detail?.isNotBlank() == true) + } + @Test fun `agents failure retries then transitions to Error`() = runBlocking { mock.agentsStatus = 500 @@ -237,6 +253,7 @@ class KiloBackendWorkspaceTest { val err = ws.state.value as KiloWorkspaceState.Error assertTrue(err.message.contains("providers") || err.message.contains("skills")) + assertTrue(err.errors.any { it.resource == "providers" } || err.errors.any { it.resource == "skills" }) } // ------ Reload ------ diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt index b943f9d8de4..f0adfbf7ce6 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt @@ -78,7 +78,7 @@ class ConnectionPanel( isOpaque = false lineWrap = true wrapStyleWord = true - foreground = UIUtil.getContextHelpForeground() + foreground = UIUtil.getLabelForeground() } private val scroll = JBScrollPane(details).apply { @@ -227,6 +227,8 @@ class ConnectionPanel( internal fun detailsText() = details.text + internal fun detailsColor() = details.foreground + internal fun retryVisible() = retry.isVisible internal fun retryText() = retry.text diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt index 3a3df476d87..80de3621b36 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt @@ -686,15 +686,15 @@ class SessionController( if (app.status == KiloAppStatusDto.ERROR) { return SessionControllerEvent.ConnectionChanged.ShowError( - app.error ?: KiloBundle.message("session.connection.error.unknown"), - app.errors.toErrorText(), + KiloBundle.message("session.connection.error.app"), + app.errors.toErrorText() ?: app.error, ) } if (workspace.status == KiloWorkspaceStatusDto.ERROR) { return SessionControllerEvent.ConnectionChanged.ShowError( - workspace.error ?: KiloBundle.message("session.connection.error.unknown"), - null, + KiloBundle.message("session.connection.error.workspace"), + workspace.errors.toErrorText() ?: workspace.error, "workspace", ) } diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index d4df97b4b5a..b07d44b5b36 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -1,4 +1,6 @@ session.connection.connecting=Loading... +session.connection.error.app=Connection failed +session.connection.error.workspace=Workspace loading failed session.connection.error.unknown=Unknown error session.connection.retry=Try again session.connection.warning.config=Configuration warnings diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt index 3d29188009f..772275be866 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt @@ -50,6 +50,7 @@ class ConnectionPanelTest : SessionControllerTestBase() { assertFalse(panel.toggleExpanded()) assertFalse(panel.detailsVisible()) assertEquals("stderr line\nconfig: HTTP 500: broken", panel.detailsText()) + assertEquals(UIUtil.getLabelForeground(), panel.detailsColor()) assertTrue(panel.retryVisible()) assertFalse(panel.retryFocusable()) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ConnectionDelayTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ConnectionDelayTest.kt index a753eb1e640..d5269a7ca86 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ConnectionDelayTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ConnectionDelayTest.kt @@ -93,7 +93,7 @@ class ConnectionDelayTest : SessionControllerTestBase() { pause(80) val event = events.filterIsInstance().single() - assertEquals("CLI startup failed", event.summary) + assertEquals("Connection failed", event.summary) assertEquals("stderr line\nconfig: HTTP 500", event.detail) } @@ -111,7 +111,8 @@ class ConnectionDelayTest : SessionControllerTestBase() { pause(150) val errors = events.filterIsInstance() - assertEquals(listOf("second"), errors.map { it.summary }) + assertEquals(listOf("Connection failed"), errors.map { it.summary }) + assertEquals(listOf("second"), errors.map { it.detail }) } fun `test persistent workspace error is delayed`() { @@ -122,15 +123,19 @@ class ConnectionDelayTest : SessionControllerTestBase() { flush() events.clear() - projectRpc.state.value = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.ERROR, error = "workspace failed") + projectRpc.state.value = KiloWorkspaceStateDto( + status = KiloWorkspaceStatusDto.ERROR, + error = "workspace failed", + errors = listOf(LoadErrorDto(resource = "providers", detail = "bad provider json")), + ) pause(20) assertFalse(events.any { it is SessionControllerEvent.ConnectionChanged.ShowError }) pause(80) val event = events.filterIsInstance().single() - assertEquals("workspace failed", event.summary) - assertNull(event.detail) + assertEquals("Workspace loading failed", event.summary) + assertEquals("providers: bad provider json", event.detail) } fun `test ready hides visible delayed connection banner immediately`() { diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloWorkspaceStateDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloWorkspaceStateDto.kt index c1dd8585db5..8b162af5d14 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloWorkspaceStateDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloWorkspaceStateDto.kt @@ -27,4 +27,5 @@ data class KiloWorkspaceStateDto( val commands: List = emptyList(), val skills: List = emptyList(), val error: String? = null, + val errors: List = emptyList(), )