diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/KiloReadyConfigurable.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/KiloReadyConfigurable.kt index 8fa4d4344b6..3e33ca74916 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/KiloReadyConfigurable.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/KiloReadyConfigurable.kt @@ -66,6 +66,7 @@ abstract class KiloReadyConfigurable : SearchableConfigurable, Configurable.NoSc override fun disposeUIResources() { val panel = ready val cs = scope + if (panel is SettingsOverlayPanel) panel.setOverlayHost(null) shell = null scope = null ready = null @@ -94,7 +95,9 @@ abstract class KiloReadyConfigurable : SearchableConfigurable, Configurable.NoSc val cs = scope ?: return val panel = createReadyComponent(cs) ready = panel - shell?.setContent(panel) + val root = shell + if (panel is SettingsOverlayPanel) panel.setOverlayHost(root) + root?.setContent(panel) onReadyComponentCreated(panel) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsOverlayPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsOverlayPanel.kt index 3c367caf839..5547a085923 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsOverlayPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsOverlayPanel.kt @@ -2,10 +2,12 @@ package ai.kilocode.client.settings.base import ai.kilocode.client.ui.LayeredOverlayPanel import ai.kilocode.client.ui.UiStyle +import com.intellij.util.concurrency.annotations.RequiresEdt import java.awt.Rectangle internal open class SettingsOverlayPanel : LayeredOverlayPanel() { val progress = SettingsProgressOverlay() + private var host: SettingsOverlayPanel? = null init { addOverlay(progress) { pane, child -> @@ -19,31 +21,55 @@ internal open class SettingsOverlayPanel : LayeredOverlayPanel() { } } - fun showProgress(text: String) { - progress.showProgress(text) - syncOverlay() - } - - fun showProgress(text: String, cancelText: String, cancel: () -> Unit) { - progress.showProgress(text, cancelText, cancel) - syncOverlay() - } - - fun updateProgress(text: String) { - progress.updateProgress(text) - syncOverlay() - } - - fun showError(text: String) { - progress.showError(text) - syncOverlay() - } - - fun clearProgress() { + @RequiresEdt + fun setOverlayHost(host: SettingsOverlayPanel?) { + if (host === this) { + this.host = null + return + } + this.host?.clearProgress() + this.host = host progress.clearProgress() syncOverlay() } + @RequiresEdt + fun showProgress(text: String) { + val panel = target() + panel.progress.showProgress(text) + panel.syncOverlay() + } + + @RequiresEdt + fun showProgress(text: String, cancelText: String, cancel: () -> Unit) { + val panel = target() + panel.progress.showProgress(text, cancelText, cancel) + panel.syncOverlay() + } + + @RequiresEdt + fun updateProgress(text: String) { + val panel = target() + panel.progress.updateProgress(text) + panel.syncOverlay() + } + + @RequiresEdt + fun showError(text: String) { + val panel = target() + panel.progress.showError(text) + panel.syncOverlay() + } + + @RequiresEdt + fun clearProgress() { + val panel = target() + panel.progress.clearProgress() + panel.syncOverlay() + } + + private fun target(): SettingsOverlayPanel = host ?: this + private fun syncOverlay() { overlay.revalidate() overlay.repaint() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsProgressOverlay.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsProgressOverlay.kt index 3e81bcfbf66..8b7842878ae 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsProgressOverlay.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsProgressOverlay.kt @@ -29,6 +29,7 @@ internal class SettingsProgressOverlay : JPanel(BorderLayout(UiStyle.Gap.md(), 0 button.isVisible = false isVisible = false syncColors() + UiStyle.Components.actionButton(button) } fun showProgress(text: String) { @@ -72,6 +73,7 @@ internal class SettingsProgressOverlay : JPanel(BorderLayout(UiStyle.Gap.md(), 0 } button.text = text button.addActionListener { action() } + UiStyle.Components.actionButton(button) button.isVisible = true } @@ -88,6 +90,7 @@ internal class SettingsProgressOverlay : JPanel(BorderLayout(UiStyle.Gap.md(), 0 override fun updateUI() { super.updateUI() syncColors() + cancel?.let { UiStyle.Components.actionButton(it) } } private fun syncColors() { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProviderListRenderer.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProviderListRenderer.kt index 82cec6aadd9..c9a20c283ea 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProviderListRenderer.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProviderListRenderer.kt @@ -22,7 +22,6 @@ import javax.swing.JList import javax.swing.JPanel import javax.swing.ListCellRenderer import javax.swing.SwingConstants -import javax.swing.UIManager private const val ACTION_GAP = 8 @@ -93,25 +92,27 @@ internal class ProviderListRenderer( private val mark = icon.align(HAlign.CENTER, VAlign.TOP) private val title = SimpleColoredComponent() private val desc = JBLabel() - private val text = JPanel(BorderLayout()).apply { - add(title, BorderLayout.NORTH) - add(desc, BorderLayout.SOUTH) - } + private val text = Stack.vertical().next(title).next(desc) private val actions = Stack.horizontal(JBUI.scale(ACTION_GAP)) - private val row = Stack.horizontal(UiStyle.Gap.md()).next(mark).next(text) + private val actionPane = actions.align(HAlign.RIGHT, VAlign.CENTER) + private val row = JPanel(BorderLayout(UiStyle.Gap.md(), 0)).apply { + add(mark, BorderLayout.WEST) + add(text, BorderLayout.CENTER) + add(actionPane, BorderLayout.EAST) + } private val wrap = PickerRow() init { isOpaque = true top.isOpaque = true - UiStyle.Components.transparent(row, mark, icon, title, text, desc, actions) + UiStyle.Components.transparent(row, mark, icon, title, text, desc, actions, actionPane) row.border = JBUI.Borders.empty( UiStyle.Gap.md(), UiStyle.Gap.lg(), UiStyle.Gap.md(), UiStyle.Gap.pad(), ) - wrap.setContent(row, actions) + wrap.setContent(row) add(top, BorderLayout.NORTH) add(wrap, BorderLayout.CENTER) } @@ -145,12 +146,13 @@ internal class ProviderListRenderer( desc.foreground = weak actions.removeAll() - for (action in visibleActions(value, selected)) { + val visible = visibleActions(value, selected) + actions.isVisible = visible.isNotEmpty() + actionPane.isVisible = visible.isNotEmpty() + for (action in visible) { actions.add(ActionLabel(action).apply { isEnabled = value.enabled(action) - foreground = if (isEnabled) UIManager.getColor("Button.foreground") ?: UIUtil.getLabelForeground() - else UIManager.getColor("Button.disabledText") ?: UIUtil.getContextHelpForeground() - background = UIManager.getColor("Button.background") + UiStyle.Components.actionLabel(this, isEnabled) }) } top.invalidate() @@ -168,11 +170,7 @@ internal class ProviderListRenderer( private class ActionLabel(action: ProviderListAction) : JBLabel(text(action)) { init { horizontalAlignment = SwingConstants.CENTER - border = JBUI.Borders.compound( - JBUI.Borders.customLine(UIUtil.getBoundsColor()), - JBUI.Borders.empty(UiStyle.Gap.sm(), UiStyle.Gap.pad()), - ) - isOpaque = true + UiStyle.Components.actionLabel(this) } } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUi.kt index a9f721e949c..48609b3d7fd 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUi.kt @@ -225,7 +225,7 @@ internal class ProvidersSettingsUi( withContext(edt) { if (!active(id)) return@withContext setBusy(false) - showError("${e::class.simpleName}: ${e.message}") + clearProgress() } } catch (e: CancellationException) { LOG.info("provider settings ui $name: coroutine cancelled durationMs=${System.currentTimeMillis() - start}") diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt index 0e1c0b047d5..421f20a67a5 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt @@ -6,6 +6,7 @@ import com.intellij.util.ui.JBFont import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil import java.awt.Color +import javax.swing.AbstractButton import javax.swing.JComponent import javax.swing.UIManager @@ -197,5 +198,35 @@ object UiStyle { fun transparent(vararg components: JComponent) { components.forEach { it.isOpaque = false } } + + fun actionForeground(enabled: Boolean): Color = if (enabled) { + UIManager.getColor("Button.foreground") ?: UIUtil.getLabelForeground() + } else { + UIManager.getColor("Button.disabledText") ?: UIUtil.getContextHelpForeground() + } + + fun actionBackground(): Color = UIManager.getColor("Button.background") ?: UIUtil.getPanelBackground() + + fun actionBorder() = JBUI.Borders.compound( + JBUI.Borders.customLine(UIUtil.getBoundsColor()), + JBUI.Borders.empty(Gap.sm(), Gap.pad()), + ) + + fun actionLabel(component: JComponent, enabled: Boolean = component.isEnabled) { + component.foreground = actionForeground(enabled) + component.background = actionBackground() + component.border = actionBorder() + component.isOpaque = true + } + + fun actionButton(button: AbstractButton) { + button.foreground = actionForeground(button.isEnabled) + button.background = actionBackground() + button.border = actionBorder() + button.isOpaque = true + button.isBorderPainted = true + button.isContentAreaFilled = false + button.isFocusPainted = false + } } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/KiloReadyConfigurableTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/KiloReadyConfigurableTest.kt index 9af05089807..f56abfc629c 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/KiloReadyConfigurableTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/KiloReadyConfigurableTest.kt @@ -126,8 +126,33 @@ class KiloReadyConfigurableTest : BasePlatformTestCase() { assertTrue(view.disposedOnEdt) } - private fun create(): FakeConfigurable { - val view = FakeConfigurable() + fun `test ready settings overlay is hosted by outer shell`() { + val view = create(overlay = true) + val root = edt { view.createComponent() as SettingsPanel } + flushUntil { rpc.connected } + + rpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + flushUntil { edt { view.readyPanel != null } } + + edt { + val ready = requireNotNull(view.readyPanel) + ready.showProgress("Authorizing provider") + + assertTrue(text(root).contains("Authorizing provider")) + assertTrue(root.progress.isVisible) + assertFalse(ready.progress.isVisible) + assertTrue(root.overlay.components.any { it === root.progress }) + assertFalse(ready.overlay.components.any { it === root.progress }) + } + + edt { view.disposeUIResources() } + cfg = null + + edt { assertFalse(root.progress.isVisible) } + } + + private fun create(overlay: Boolean = false): FakeConfigurable { + val view = FakeConfigurable(overlay) cfg = view return view } @@ -175,9 +200,11 @@ class KiloReadyConfigurableTest : BasePlatformTestCase() { visit(root) } - private class FakeConfigurable : KiloReadyConfigurable() { + private class FakeConfigurable(private val overlay: Boolean = false) : KiloReadyConfigurable() { val field = JPanel() val focuses = mutableListOf() + var readyPanel: SettingsPanel? = null + private set var created = 0 private set var disposed = 0 @@ -196,6 +223,12 @@ class KiloReadyConfigurableTest : BasePlatformTestCase() { override fun createReadyComponent(cs: CoroutineScope): JComponent { created++ + if (overlay) { + val panel = SettingsPanel() + panel.setContent(JPanel().apply { add(JLabel("Ready content")) }) + readyPanel = panel + return panel + } return JPanel().apply { add(JLabel("Ready content")) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsRowsTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsRowsTest.kt index 41e6a3e58ad..8267e793990 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsRowsTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsRowsTest.kt @@ -119,6 +119,8 @@ class SettingsRowsTest : BasePlatformTestCase() { assertFalse(text(panel.overlay).contains("Sign in to Kilo Code")) assertTrue(panel.overlay.components.any { it === panel.progress }) assertTrue(text(panel.progress).contains("Loading models...")) + val scroll = components(panel.content).filterIsInstance().single() + assertFalse(components(scroll.viewport.view as JComponent).any { it === panel.progress }) } fun `test settings panel tracks viewport width without horizontal scroll`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUiTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUiTest.kt index 46e1a4c5cd0..16a8efca5bd 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUiTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUiTest.kt @@ -2,6 +2,7 @@ package ai.kilocode.client.settings.providers import ai.kilocode.client.app.KiloProviderService import ai.kilocode.client.testing.FakeProviderRpcApi +import ai.kilocode.client.ui.UiStyle import ai.kilocode.rpc.dto.CustomProviderConfigDto import ai.kilocode.rpc.dto.ModelDto import ai.kilocode.rpc.dto.ProviderAuthMethodDto @@ -21,10 +22,12 @@ import com.intellij.util.ui.UIUtil import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout import java.awt.BorderLayout import java.awt.Container import java.awt.Dimension @@ -327,6 +330,24 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() { } } + fun `test renderer lays out action labels with visible bounds`() { + edt { + val row = ProviderListRow(provider("openai", "OpenAI"), "Popular providers", listOf(ProviderListAction.CONNECT)) + val list = JBList(listOf(row)) + val renderer = ProviderListRenderer(com.intellij.ui.CollectionListModel(listOf(row))) + + renderer.getListCellRendererComponent(list, row, 0, true, false) + renderer.setSize(320, renderer.preferredSize.height) + renderer.doLayout() + components(renderer).filterIsInstance().forEach { it.doLayout() } + + val label = components(renderer).filterIsInstance().single { it.text == "Connect" } + assertTrue(label.isShowing || label.isVisible) + assertTrue(label.width > 0) + assertTrue(label.height > 0) + } + } + fun `test renderer hides unselected unconnected action labels`() { edt { val row = ProviderListRow(provider("cloudflare", "Cloudflare"), "All providers", listOf(ProviderListAction.CONNECT)) @@ -437,6 +458,7 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() { val area = ProviderListRenderer.actionBounds(list, bounds, row, selected = true).getValue(ProviderListAction.CONNECT) assertTrue(kotlin.math.abs((bounds.y + bounds.height / 2) - (area.y + area.height / 2)) <= 1) + assertTrue(bounds.contains(area)) } } @@ -489,6 +511,10 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() { edt { assertTrue(text(panel).contains("Cancel")) + val cancel = components(panel).filterIsInstance().single { it.text == "Cancel" && it.isVisible } + assertEquals(UiStyle.Components.actionForeground(true), cancel.foreground) + assertEquals(UiStyle.Components.actionBackground(), cancel.background) + assertEquals(requireNotNull(UiStyle.Components.actionBorder()).getBorderInsets(cancel), requireNotNull(cancel.border).getBorderInsets(cancel)) assertTrue(rows(panel).single().disabled) assertTrue(ProviderListRenderer.visibleActions(rows(panel).single(), selected = true).isEmpty()) panel.reload() @@ -551,6 +577,41 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() { } } + fun `test provider oauth timeout clears progress without error`() { + val ready = CompletableDeferred() + val rpc = installProvider( + ProviderSettingsDto( + providers = listOf(provider("github-copilot", "GitHub Copilot")), + auth = mapOf("github-copilot" to listOf(ProviderAuthMethodDto("oauth", "OAuth"))), + ), + ) + rpc.authorizesReady.add(ready) + val panel = edt { createUi() } + + flushUntil { rpc.stateCalls.size == 1 && edt { rows(panel).map { it.key } == listOf("github-copilot") } } + edt { triggerPrimary(panel) } + flushUntil { rpc.authorizes.size == 1 && edt { text(panel).contains("Starting OAuth for GitHub Copilot") } } + val timeout = runBlocking { + try { + withTimeout(1) { delay(Long.MAX_VALUE) } + error("timeout expected") + } catch (e: TimeoutCancellationException) { + e + } + } + ready.completeExceptionally(timeout) + + flushUntil { edt { rows(panel).single().disabled.not() && !text(panel).contains("Starting OAuth") } } + + edt { + val visible = text(panel) + assertFalse(visible.contains("TimeoutCancellationException")) + assertFalse(visible.contains("OAuth timed out")) + assertFalse(visible.contains("Cancel")) + assertTrue(rpc.callbacks.isEmpty()) + } + } + fun `test provider action failure returns error state`() = runBlocking { val cs = CoroutineScope(SupervisorJob()) scope = cs