From 09572d3d47685e3e6f5fffeb8f67bfd73d90f21f Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 19 May 2026 13:23:45 -0400 Subject: [PATCH] feat(desktop): polish JetBrains account switcher --- .../jetbrains-session-account-overlay.md | 5 + .../ai/kilocode/client/session/SessionUi.kt | 39 ++ .../session/controller/SessionController.kt | 90 +++++ .../controller/SessionControllerEvent.kt | 20 + .../session/ui/account/AccountChoice.kt | 5 + .../ui/account/AccountPickerRenderer.kt | 69 ++++ .../ui/account/SessionAccountOverlay.kt | 365 ++++++++++++++++++ .../session/ui/model/ModelPickerRenderer.kt | 42 +- .../client/session/ui/prompt/PromptPanel.kt | 58 +-- .../settings/profile/LoggedInProfileUi.kt | 3 +- .../settings/profile/LoggedOutProfileUi.kt | 3 +- .../ai/kilocode/client/ui/FilledBadgeIcon.kt | 42 ++ .../ai/kilocode/client/ui/PickerButton.kt | 15 +- .../RoundedContentPanel.kt} | 33 +- .../kotlin/ai/kilocode/client/ui/UiStyle.kt | 27 ++ .../resources/messages/KiloBundle.properties | 2 + .../client/session/SessionUiLayoutTest.kt | 77 +++- .../session/controller/ViewSwitchingTest.kt | 139 +++++++ .../ui/account/SessionAccountOverlayTest.kt | 320 +++++++++++++++ 19 files changed, 1249 insertions(+), 105 deletions(-) create mode 100644 .changeset/jetbrains-session-account-overlay.md create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/account/AccountChoice.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/account/AccountPickerRenderer.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/account/SessionAccountOverlay.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/FilledBadgeIcon.kt rename packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/{settings/profile/ProfileCardPanel.kt => ui/RoundedContentPanel.kt} (50%) create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/account/SessionAccountOverlayTest.kt diff --git a/.changeset/jetbrains-session-account-overlay.md b/.changeset/jetbrains-session-account-overlay.md new file mode 100644 index 00000000000..600bc055108 --- /dev/null +++ b/.changeset/jetbrains-session-account-overlay.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Show account login, switching, and balance controls on the empty JetBrains session screen. 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 612032ca90d..0df19d7b054 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 @@ -13,6 +13,7 @@ import ai.kilocode.client.session.ui.ReasoningPicker import ai.kilocode.client.session.ui.mode.ModePicker import ai.kilocode.client.session.ui.model.ModelPicker import ai.kilocode.client.session.ui.prompt.PromptPanel +import ai.kilocode.client.session.ui.account.SessionAccountOverlay import ai.kilocode.client.session.ui.SessionRootPanel import ai.kilocode.client.session.ui.SessionMessageListPanel import ai.kilocode.client.session.ui.header.SessionHeaderPanel @@ -21,17 +22,24 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.session.controller.EVENT_FLUSH_MS import ai.kilocode.client.session.controller.SessionController import ai.kilocode.client.session.controller.SessionControllerEvent +import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.PermissionView import ai.kilocode.client.session.views.question.QuestionView +import ai.kilocode.client.settings.profile.UserProfileConfigurable import ai.kilocode.log.ChatLogSummary +import com.intellij.util.ui.JBUI import ai.kilocode.log.KiloLog import com.intellij.ide.ui.LafManagerListener import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.editor.colors.EditorColorsListener import com.intellij.openapi.editor.colors.EditorColorsManager import com.intellij.openapi.Disposable +import com.intellij.openapi.options.Configurable +import com.intellij.openapi.options.ConfigurableWithId +import com.intellij.openapi.options.ShowSettingsUtil import com.intellij.openapi.project.Project import com.intellij.openapi.util.registry.Registry +import java.util.function.Predicate import kotlinx.coroutines.CoroutineScope import java.awt.BorderLayout import javax.swing.BoxLayout @@ -79,10 +87,12 @@ class SessionUi( beforeUpdate = { if (opening) false else scroll.atBottom() }, afterUpdate = { if (!opening) scroll.followBottom(it) }, loaded = ::onSessionLoaded, + openProfileAction = ::openProfileSettings, ) private lateinit var root: SessionRootPanel + private lateinit var account: SessionAccountOverlay private lateinit var sessionContent: JPanel @@ -137,6 +147,23 @@ class SessionUi( private fun buildUi() { root = SessionRootPanel() + account = SessionAccountOverlay( + select = { org -> controller.selectOrganization(org) }, + login = { controller.openProfile() }, + profile = { controller.openProfile() }, + ) + root.addOverlay(account) { pane, child -> + val size = child.preferredSize + val top = JBUI.scale(SessionUiStyle.View.Prompt.PANEL_VERTICAL_PADDING) + val right = JBUI.scale(SessionUiStyle.View.Prompt.PANEL_HORIZONTAL_PADDING) + java.awt.Rectangle( + pane.width - size.width - right, + top, + size.width, + size.height, + ) + } + sessionContent = JPanel(BorderLayout()) blankBody = JPanel(BorderLayout()).apply { @@ -234,6 +261,8 @@ class SessionUi( } is SessionControllerEvent.ConnectionChanged -> Unit + + is SessionControllerEvent.AccountOverlayChanged -> account.onEvent(event) } } @@ -340,6 +369,16 @@ class SessionUi( refresh() } + private fun openProfileSettings() { + ShowSettingsUtil.getInstance().showSettingsDialog( + project, + Predicate { cfg: Configurable -> + cfg is ConfigurableWithId && cfg.getId() == UserProfileConfigurable.ID + }, + { cfg: Configurable -> cfg.focusOn(UserProfileConfigurable.FOCUS_ACCOUNT_COMBO) }, + ) + } + override fun dispose() {} } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt index 3c0da06ba82..c8303bb45b6 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt @@ -25,6 +25,8 @@ import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto import ai.kilocode.rpc.dto.LoadErrorDto import ai.kilocode.rpc.dto.ModelSelectionDto +import ai.kilocode.rpc.dto.ProfileDto +import ai.kilocode.rpc.dto.ProfileStatusDto import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto import ai.kilocode.rpc.dto.PermissionReplyDto import ai.kilocode.rpc.dto.PermissionRequestDto @@ -74,8 +76,11 @@ class SessionController( private val beforeUpdate: () -> Boolean = { false }, private val afterUpdate: (Boolean) -> Unit = {}, private val loaded: (Boolean) -> Unit = {}, + private val openProfileAction: () -> Unit = {}, ) : Disposable { + private data class OrganizationTarget(val org: String?) + companion object { private val LOG = KiloLog.create(SessionController::class.java) internal const val RECENT_LIMIT = 5 @@ -111,6 +116,11 @@ class SessionController( private var connectionState: SessionControllerEvent.ConnectionChanged? = null private var connectionTargetState: SessionControllerEvent.ConnectionChanged? = null private val connectionDelay = DelayedState(displayMs) + private var acctState: SessionControllerEvent.AccountOverlayChanged = + SessionControllerEvent.AccountOverlayChanged.Hide + private var acctAllowed = false + private var lastProfile: ProfileDto? = null + private var target: OrganizationTarget? = null val ready: Boolean get() = model.isReady() internal val blank: Boolean get() = ref == null && model.isEmpty() && !model.showSession @@ -371,6 +381,7 @@ class SessionController( model.version = app.version syncModelSelection() syncConnectionState() + refreshAccountOverlay() } } } @@ -845,6 +856,79 @@ class SessionController( else -> KiloBundle.message("session.status.considering") } + fun selectOrganization(org: String?) { + assertEdt() + val next = OrganizationTarget(org) + if (target == next) return + target = next + refreshAccountOverlay() + cs.launch { + try { + app.setOrganization(org) + } catch (e: Exception) { + LOG.warn("account switch failed org=$org message=${e.message}", e) + edt { + if (disposed) return@edt + target = null + refreshAccountOverlay() + } + } + } + } + + fun openProfile() { + assertEdt() + openProfileAction() + } + + private fun accountSnapshot(): SessionControllerEvent.AccountOverlaySnapshot { + val state = model.app + val prof = state.profile + val pending = prof == null && state.progress?.profile == ProfileStatusDto.PENDING + val current = when { + prof != null -> prof + pending -> lastProfile + else -> null + } + if (prof != null) { + lastProfile = prof + if (target?.org == prof.currentOrgId) target = null + } + if (!pending && prof == null) { + lastProfile = null + target = null + } + return SessionControllerEvent.AccountOverlaySnapshot( + status = state.status, + profile = current, + transient = pending, + switching = target != null, + targetOrgId = target?.org, + ) + } + + private fun showAccountOverlay() { + acctAllowed = true + setAccountOverlayState(SessionControllerEvent.AccountOverlayChanged.Show(accountSnapshot())) + } + + private fun hideAccountOverlay() { + acctAllowed = false + setAccountOverlayState(SessionControllerEvent.AccountOverlayChanged.Hide) + } + + private fun refreshAccountOverlay() { + if (!acctAllowed) return + setAccountOverlayState(SessionControllerEvent.AccountOverlayChanged.Show(accountSnapshot())) + } + + private fun setAccountOverlayState(event: SessionControllerEvent.AccountOverlayChanged) { + if (acctState == event) return + fire(event) { + acctState = event + } + } + fun refreshRecents(force: Boolean = false) { assertEdt() if (!canUseRecents()) return @@ -896,6 +980,11 @@ class SessionController( setRecentSessionsState(RecentsState.Idle) } } + when (event) { + is SessionControllerEvent.ViewChanged.ShowRecents -> showAccountOverlay() + is SessionControllerEvent.ViewChanged.ShowProgress -> hideAccountOverlay() + is SessionControllerEvent.ViewChanged.ShowSession -> hideAccountOverlay() + } } private fun openLocal() { @@ -1004,6 +1093,7 @@ class SessionController( val block: () -> Unit = { if (!disposed) { viewState?.let(listener::onEvent) + listener.onEvent(acctState) connectionState?.let(listener::onEvent) } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionControllerEvent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionControllerEvent.kt index ba6217af592..50f644fe327 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionControllerEvent.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionControllerEvent.kt @@ -2,6 +2,8 @@ package ai.kilocode.client.session.controller import ai.kilocode.client.session.model.SessionModel import ai.kilocode.client.session.model.SessionModelEvent +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.ProfileDto import ai.kilocode.rpc.dto.SessionDto /** @@ -34,6 +36,24 @@ sealed class SessionControllerEvent { } } + data class AccountOverlaySnapshot( + val status: KiloAppStatusDto, + val profile: ProfileDto?, + val transient: Boolean = false, + val switching: Boolean = false, + val targetOrgId: String? = null, + ) + + sealed class AccountOverlayChanged : SessionControllerEvent() { + data class Show(val account: AccountOverlaySnapshot) : AccountOverlayChanged() { + override fun toString() = "AccountOverlayChanged show loggedIn=${account.profile != null}" + } + + data object Hide : AccountOverlayChanged() { + override fun toString() = "AccountOverlayChanged hide" + } + } + sealed class ConnectionChanged : SessionControllerEvent() { data object Hide : ConnectionChanged() { override fun toString() = "ConnectionChanged hide" diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/account/AccountChoice.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/account/AccountChoice.kt new file mode 100644 index 00000000000..52bbba4eaf5 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/account/AccountChoice.kt @@ -0,0 +1,5 @@ +package ai.kilocode.client.session.ui.account + +internal data class AccountChoice(val org: String?, val title: String) { + override fun toString() = title +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/account/AccountPickerRenderer.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/account/AccountPickerRenderer.kt new file mode 100644 index 00000000000..b4b6e4c71ee --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/account/AccountPickerRenderer.kt @@ -0,0 +1,69 @@ +package ai.kilocode.client.session.ui.account + +import ai.kilocode.client.session.ui.PickerRow +import ai.kilocode.client.ui.UiStyle +import com.intellij.icons.AllIcons +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.EmptyIcon +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import java.awt.BorderLayout +import java.awt.Component +import javax.swing.JList +import javax.swing.JPanel +import javax.swing.ListCellRenderer +import javax.swing.SwingConstants + +internal class AccountPickerRenderer( + private val active: () -> String?, +) : JPanel(BorderLayout()), ListCellRenderer { + companion object { + val checked: javax.swing.Icon = AllIcons.Actions.Checked + val empty: javax.swing.Icon = EmptyIcon.create(checked) + } + + private val icon = JBLabel().apply { + horizontalAlignment = SwingConstants.CENTER + verticalAlignment = SwingConstants.CENTER + } + private val title = JBLabel().apply { + horizontalAlignment = SwingConstants.LEFT + verticalAlignment = SwingConstants.CENTER + } + private val row = JPanel(BorderLayout(UiStyle.Gap.md(), 0)) + private val wrap = PickerRow() + + init { + UiStyle.Components.transparent(this, icon, title, row) + row.border = JBUI.Borders.empty( + UiStyle.Gap.md(), + UiStyle.Gap.lg(), + UiStyle.Gap.md(), + UiStyle.Gap.lg(), + ) + row.add(icon, BorderLayout.WEST) + row.add(title, BorderLayout.CENTER) + wrap.setContent(row) + add(wrap, BorderLayout.CENTER) + } + + override fun getListCellRendererComponent( + list: JList, + value: AccountChoice, + index: Int, + selected: Boolean, + focused: Boolean, + ): Component { + val focus = selected || list.hasFocus() || focused + val fg = UIUtil.getListForeground(selected, focus) + background = list.background + wrap.update(list, selected, focus) + icon.icon = icon(value) + title.text = value.title + title.foreground = fg + return this + } + + internal fun icon(value: AccountChoice): javax.swing.Icon = + if (value.org == active()) checked else empty +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/account/SessionAccountOverlay.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/account/SessionAccountOverlay.kt new file mode 100644 index 00000000000..ce2673d1ad8 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/account/SessionAccountOverlay.kt @@ -0,0 +1,365 @@ +package ai.kilocode.client.session.ui.account + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.controller.SessionControllerEvent +import ai.kilocode.client.ui.FilledBadgeIcon +import ai.kilocode.client.ui.HoverIcon +import ai.kilocode.client.ui.PickerButton +import ai.kilocode.client.ui.RoundedContentPanel +import ai.kilocode.client.ui.UiStyle +import com.intellij.icons.AllIcons +import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.ui.CollectionListModel +import com.intellij.ui.ListUtil +import com.intellij.ui.ScrollPaneFactory +import com.intellij.ui.ScrollingUtil +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 com.intellij.util.ui.components.BorderLayoutPanel +import java.awt.CardLayout +import java.awt.Cursor +import java.awt.GridBagConstraints +import java.awt.GridBagLayout +import java.awt.event.KeyEvent +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import java.text.DecimalFormat +import javax.swing.Box +import javax.swing.BoxLayout +import javax.swing.JButton +import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.KeyStroke +import javax.swing.ListSelectionModel +import javax.swing.ScrollPaneConstants + +/** + * Compact account overlay shown in the top-right of the empty session screen. + * + * Displays logged-out prompt or logged-in account/balance info. + * Visibility is controlled entirely by [onEvent] — never set [isVisible] externally. + */ +internal class SessionAccountOverlay( + private val select: (String?) -> Unit, + private val login: () -> Unit, + private val profile: () -> Unit, +) : BorderLayoutPanel() { + + companion object { + private const val CARD_OUT = "out" + private const val CARD_IN = "in" + } + + private val loginLabel = JBLabel(KiloBundle.message("profile.notLoggedIn")).apply { + foreground = UiStyle.Colors.weak() + } + private val loginBtn = JButton(KiloBundle.message("profile.action.login")).apply { + isOpaque = false + addActionListener { login() } + } + private val outCard = JPanel(GridBagLayout()).apply { + isOpaque = false + add(loginLabel, GridBagConstraints().apply { + gridx = 0; gridy = 0; anchor = GridBagConstraints.WEST + }) + add(loginBtn, GridBagConstraints().apply { + gridx = 0; gridy = 1; anchor = GridBagConstraints.CENTER + insets = JBUI.insetsTop(UiStyle.Gap.sm()) + }) + } + + private val picker = PickerButton().apply { + isEnabled = false + text = " " + cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + if (!isEnabled || choices.isEmpty()) return + showPopup() + } + }) + } + + private val fmt = DecimalFormat("$#,##0.00") + private var balanceText: String? = null + + private val balance = JBLabel().apply { + isVisible = false + } + + private val profileBtn = HoverIcon().apply { + icon = AllIcons.General.User + toolTipText = KiloBundle.message("action.Kilo.ShowProfile.description") + accessibleContext.accessibleName = KiloBundle.message("action.Kilo.ShowProfile.text") + addActionListener { profile() } + } + + private val row = JPanel().apply { + layout = BoxLayout(this, BoxLayout.X_AXIS) + isOpaque = false + add(picker) + add(Box.createHorizontalStrut(UiStyle.Gap.md())) + add(balance) + add(Box.createHorizontalStrut(UiStyle.Gap.md())) + add(profileBtn) + } + + private val panel = RoundedContentPanel(UiStyle.Gap.lg(), UiStyle.Gap.lg()).apply { + addToCenter(row) + } + + private val inCard = JPanel(GridBagLayout()).apply { + isOpaque = false + add(panel, GridBagConstraints().apply { + gridx = 0; gridy = 0; fill = GridBagConstraints.HORIZONTAL + }) + } + + private val cardLayout = CardLayout() + private val cards = JPanel(cardLayout).apply { + isOpaque = false + add(outCard, CARD_OUT) + add(inCard, CARD_IN) + } + + private var choices: List = emptyList() + private var currentOrgId: String? = null + + init { + isOpaque = false + isVisible = false + addToCenter(cards) + } + + fun onEvent(event: SessionControllerEvent.AccountOverlayChanged) { + var layout = false + var paint = false + when (event) { + is SessionControllerEvent.AccountOverlayChanged.Hide -> { + if (isVisible) { + isVisible = false + layout = true + paint = true + } + } + is SessionControllerEvent.AccountOverlayChanged.Show -> { + val snap = event.account + val prof = snap.profile + if (prof == null) { + if (!snap.transient) { + layout = showCard(CARD_OUT) || layout + if (!isVisible) { + isVisible = true + layout = true + } + } + } else { + layout = updateLoggedIn(prof, snap.switching, snap.targetOrgId) || layout + layout = showCard(CARD_IN) || layout + if (!isVisible) { + isVisible = true + layout = true + } + } + } + } + if (layout) revalidate() + if (layout || paint) repaint() + } + + private fun activeCard(): String? { + for (i in 0 until cards.componentCount) { + val comp = cards.getComponent(i) + if (comp.isVisible) return if (comp === inCard) CARD_IN else CARD_OUT + } + return null + } + + private fun showCard(card: String): Boolean { + if (activeCard() == card) return false + cardLayout.show(cards, card) + return true + } + + private fun updateLoggedIn(prof: ai.kilocode.rpc.dto.ProfileDto, switching: Boolean, target: String?): Boolean { + var layout = false + val orgs = prof.organizations + val next = listOf(AccountChoice(null, KiloBundle.message("profile.personalAccount"))) + + orgs.map { org -> AccountChoice(org.id, org.name) } + if (next != choices) { + choices = next + layout = true + } + + if (currentOrgId != prof.currentOrgId) currentOrgId = prof.currentOrgId + + val activeId = if (switching) target else prof.currentOrgId + val active = choices.firstOrNull { it.org == activeId } ?: choices.firstOrNull() + val title = "${active?.title ?: " "} ▾" + if (picker.text != title) { + picker.text = title + layout = true + } + + val enabled = !switching + if (picker.isEnabled != enabled) { + picker.isEnabled = enabled + picker.repaint() + } + + val tip = if (switching) { + KiloBundle.message("profile.switchingAccount") + } else { + KiloBundle.message("session.account.switcher") + } + if (picker.toolTipText != tip) picker.toolTipText = tip + + if (!picker.isVisible) { + picker.isVisible = true + layout = true + } + + layout = syncBalance(prof) || layout + return layout + } + + private fun syncBalance(prof: ai.kilocode.rpc.dto.ProfileDto): Boolean { + var layout = false + val next = prof.balance?.let { fmt.format(it.balance) } + if (next == null) { + if (balance.isVisible) { + balance.isVisible = false + layout = true + } + if (balance.icon != null) { + balance.icon = null + } + if (balance.toolTipText != null) balance.toolTipText = null + balanceText = null + } else { + if (!balance.isVisible) { + balance.isVisible = true + layout = true + } + if (balanceText != next || balance.icon == null) { + balance.icon = FilledBadgeIcon( + next, + UiStyle.Colors.badgeBg(), + UiStyle.Colors.badgeFg(), + ) + layout = true + } + val tip = KiloBundle.message("session.account.balance", next) + if (balance.toolTipText != tip) balance.toolTipText = tip + balanceText = next + } + return layout + } + + private fun showPopup() { + val bg = UiStyle.Colors.cardBg() + val model = CollectionListModel(choices) + val list = JBList(model).apply { + selectionMode = ListSelectionModel.SINGLE_SELECTION + background = bg + border = JBUI.Borders.empty(UiStyle.Gap.xs(), 0) + cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + } + list.cellRenderer = AccountPickerRenderer { currentOrgId } + + val idx = choices.indexOfFirst { it.org == currentOrgId }.takeIf { it >= 0 } ?: 0 + if (idx >= 0) { + list.selectedIndex = idx + ScrollingUtil.ensureIndexIsVisible(list, idx, 0) + } + + lateinit var popup: com.intellij.openapi.ui.popup.JBPopup + + fun activate(choice: AccountChoice) { + if (choice.org != currentOrgId) select(choice.org) + popup.closeOk(null) + } + + list.addMouseListener(object : MouseAdapter() { + override fun mouseReleased(e: MouseEvent) { + if (!UIUtil.isActionClick(e, MouseEvent.MOUSE_RELEASED, true)) return + val row = list.locationToIndex(e.point) + val bounds = row.takeIf { it >= 0 }?.let { list.getCellBounds(it, it) } ?: return + if (!bounds.contains(e.point)) return + activate(model.getElementAt(row)) + } + }) + + ListUtil.installAutoSelectOnMouseMove(list) + ScrollingUtil.installActions(list) + + list.registerKeyboardAction( + { list.selectedValue?.let(::activate) }, + KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), + JComponent.WHEN_FOCUSED, + ) + list.registerKeyboardAction( + { popup.cancel() }, + KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), + JComponent.WHEN_FOCUSED, + ) + + val scroll = ScrollPaneFactory.createScrollPane(list).apply { + horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED + border = JBUI.Borders.empty() + viewportBorder = JBUI.Borders.empty() + background = bg + viewport.background = bg + viewport.isOpaque = true + } + val content = RoundedContentPanel(UiStyle.Gap.sm(), UiStyle.Gap.sm()).apply { + addToCenter(scroll) + } + + popup = JBPopupFactory.getInstance() + .createComponentPopupBuilder(content, list) + .setRequestFocus(true) + .setFocusable(true) + .setCancelOnClickOutside(true) + .setCancelKeyEnabled(true) + .setCancelOnWindowDeactivation(true) + .setResizable(false) + .setMovable(false) + .createPopup() + + popup.showUnderneathOf(picker) + } + + internal fun loggedInVisible() = isVisible && cards.let { + var card = CARD_OUT + for (i in 0 until it.componentCount) { + val comp = it.getComponent(i) + if (comp.isVisible) card = if (comp === inCard) CARD_IN else CARD_OUT + } + card == CARD_IN + } + + internal fun loggedOutVisible() = isVisible && cards.let { + for (i in 0 until it.componentCount) { + val comp = it.getComponent(i) + if (comp.isVisible) return@let comp === outCard + } + false + } + + internal fun accountTitle(): String? = picker.text?.removeSuffix(" ▾")?.ifBlank { null } + internal fun pickerEnabled() = picker.isEnabled + internal fun pickerVisible() = picker.isVisible + internal fun choiceCount() = choices.size + internal fun selectedIndex() = choices.indexOfFirst { it.org == currentOrgId }.takeIf { it >= 0 } ?: 0 + internal fun panelBackground() = panel.background + internal fun panelBorderColor() = UiStyle.Colors.cardBorder() + internal fun balanceVisible() = balance.isVisible + internal fun balanceIcon() = balance.icon + internal fun balanceText() = balanceText + internal fun profileIcon() = profileBtn.icon + internal fun clickProfile() = profileBtn.doClick() +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPickerRenderer.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPickerRenderer.kt index 1dc0ea9a5ca..a6c004b46fc 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPickerRenderer.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPickerRenderer.kt @@ -2,6 +2,7 @@ package ai.kilocode.client.session.ui.model import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.ui.PickerRow +import ai.kilocode.client.ui.FilledBadgeIcon import ai.kilocode.client.ui.UiStyle import com.intellij.icons.AllIcons import com.intellij.ui.CollectionListModel @@ -12,18 +13,12 @@ import com.intellij.ui.SimpleColoredComponent import com.intellij.ui.SimpleTextAttributes import com.intellij.ui.components.JBLabel import com.intellij.util.ui.EmptyIcon -import com.intellij.util.ui.JBFont import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil import java.awt.BorderLayout -import java.awt.Component import java.awt.FlowLayout -import java.awt.Graphics -import java.awt.Graphics2D import java.awt.Point import java.awt.Rectangle -import java.awt.RenderingHints -import java.awt.font.FontRenderContext import javax.swing.Icon import javax.swing.JList import javax.swing.JPanel @@ -70,7 +65,11 @@ internal class ModelPickerRenderer( verticalAlignment = SwingConstants.CENTER } private val title = SimpleColoredComponent() - private val badge = BadgeIcon + private val badge = FilledBadgeIcon( + KiloBundle.message("model.picker.free"), + ModelText.freeBg(), + JBColor.namedColor("Kilo.ModelPicker.freeBadgeForeground", JBColor.WHITE), + ) private val provider = JBLabel() private val head = JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply { add(title) @@ -157,33 +156,4 @@ internal class ModelPickerRenderer( internal fun badgeVisible(): Boolean = head.getComponent(1).isVisible private class BadgeLabel(icon: Icon) : JBLabel(icon) - - private object BadgeIcon : Icon { - private val text = KiloBundle.message("model.picker.free") - - override fun getIconWidth(): Int { - val font = JBFont.small() - val w = font.getStringBounds(text, FontRenderContext(null, true, true)).width.toInt() - return w + JBUI.scale(12) - } - - override fun getIconHeight(): Int = JBUI.scale(16) - - override fun paintIcon(c: Component?, g: Graphics, x: Int, y: Int) { - val g2 = g.create() as Graphics2D - try { - g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) - g2.translate(x, y) - g2.color = ModelText.freeBg() - g2.fillRoundRect(0, 0, iconWidth, iconHeight, JBUI.scale(4), JBUI.scale(4)) - g2.color = JBColor.namedColor("Kilo.ModelPicker.freeBadgeForeground", JBColor.WHITE) - g2.font = JBFont.small() - val fm = g2.fontMetrics - val y = (iconHeight + fm.ascent - fm.descent) / 2 - g2.drawString(text, JBUI.scale(6), y) - } finally { - g2.dispose() - } - } - } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt index 9ba3f57bfd4..811847628f0 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt @@ -10,6 +10,7 @@ import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.ui.mode.ModePicker import ai.kilocode.client.session.ui.model.ModelPicker import ai.kilocode.client.ui.HoverIcon +import ai.kilocode.client.ui.RoundedContentPanel import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.iconButton import ai.kilocode.log.ChatLogSummary @@ -357,55 +358,22 @@ class PromptPanel( } } - private inner class PromptShell : BorderLayoutPanel() { - private val arc = JBValue.UIInteger("Button.arc", SessionUiStyle.View.Prompt.CORNER_ARC) + private inner class PromptShell : RoundedContentPanel( + JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING), + ) { private val focus = JBValue.UIInteger("Component.focusWidth", SessionUiStyle.View.Prompt.FOCUS_WIDTH) - init { - isOpaque = false - border = JBUI.Borders.empty( - JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING), - ) + override fun contentColor() = style.editorScheme.defaultBackground + + override fun outlineColor() = if (UIUtil.isFocusAncestor(editor)) { + JBUI.CurrentTheme.Focus.focusColor() + } else { + SessionUiStyle.View.line() } - override fun updateUI() { - super.updateUI() - border = JBUI.Borders.empty( - JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING), - ) - } + override fun outlineWidth() = if (UIUtil.isFocusAncestor(editor)) focus.get() else JBUI.scale(1) - override fun paintComponent(g: Graphics) { - val g2 = g.create() as Graphics2D - try { - g2.setRenderingHint( - RenderingHints.KEY_ANTIALIASING, - RenderingHints.VALUE_ANTIALIAS_ON, - ) - g2.color = style.editorScheme.defaultBackground - val size = arc.get() - g2.fillRoundRect(0, 0, width, height, size, size) - val active = UIUtil.isFocusAncestor(editor) - g2.color = if (active) { - JBUI.CurrentTheme.Focus.focusColor() - } else { - SessionUiStyle.View.line() - } - val bw = if (active) focus.get() else JBUI.scale(1) - for (idx in 0 until bw) { - val inset = idx - val w = width - inset * 2 - 1 - val h = height - inset * 2 - 1 - if (w > 0 && h > 0) { - g2.drawRoundRect(inset, inset, w, h, size, size) - } - } - } finally { - g2.dispose() - } - super.paintComponent(g) - } + override fun cornerArc() = JBUI.scale(JBUI.getInt("Button.arc", SessionUiStyle.View.Prompt.CORNER_ARC)) } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/LoggedInProfileUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/LoggedInProfileUi.kt index 15d1d493e5e..5e0f7643a72 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/LoggedInProfileUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/LoggedInProfileUi.kt @@ -1,6 +1,7 @@ package ai.kilocode.client.settings.profile import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.ui.RoundedContentPanel import ai.kilocode.client.ui.UiStyle import ai.kilocode.log.KiloLog import ai.kilocode.rpc.dto.ProfileDto @@ -61,7 +62,7 @@ internal class LoggedInProfileUi( } } - private val balanceCard = ProfileCardPanel(UiStyle.Gap.pad(), UiStyle.Gap.xl()).apply { + private val balanceCard = RoundedContentPanel(UiStyle.Gap.pad(), UiStyle.Gap.xl()).apply { name = "kilo.profile.balanceCard" addToTop(titleLabel) addToCenter(JPanel(GridBagLayout()).apply { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/LoggedOutProfileUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/LoggedOutProfileUi.kt index 0fa096ca881..9244a793d58 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/LoggedOutProfileUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/LoggedOutProfileUi.kt @@ -2,6 +2,7 @@ package ai.kilocode.client.settings.profile import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.ui.HoverIcon +import ai.kilocode.client.ui.RoundedContentPanel import ai.kilocode.client.ui.UiStyle import ai.kilocode.rpc.dto.KiloAppStatusDto import com.intellij.icons.AllIcons @@ -94,7 +95,7 @@ internal class LoggedOutProfileUi( accessibleContext.accessibleDescription = KiloBundle.message("profile.login.qr.description") } - private val codePanel = ProfileCardPanel(UiStyle.Gap.sm(), UiStyle.Gap.md()).apply { + private val codePanel = RoundedContentPanel(UiStyle.Gap.sm(), UiStyle.Gap.md()).apply { name = "kilo.login.codePanel" addMouseListener(object : MouseAdapter() { override fun mouseClicked(e: MouseEvent) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/FilledBadgeIcon.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/FilledBadgeIcon.kt new file mode 100644 index 00000000000..7c740167225 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/FilledBadgeIcon.kt @@ -0,0 +1,42 @@ +package ai.kilocode.client.ui + +import com.intellij.util.ui.JBFont +import com.intellij.util.ui.JBUI +import java.awt.Color +import java.awt.Component +import java.awt.Graphics +import java.awt.Graphics2D +import java.awt.RenderingHints +import java.awt.font.FontRenderContext +import javax.swing.Icon + +internal class FilledBadgeIcon( + private val text: String, + private val bg: Color, + private val fg: Color, +) : Icon { + override fun getIconWidth(): Int { + val font = JBFont.small() + val width = font.getStringBounds(text, FontRenderContext(null, true, true)).width.toInt() + return width + UiStyle.Gap.lg() * 2 + } + + override fun getIconHeight() = JBUI.scale(16) + + override fun paintIcon(c: Component?, g: Graphics, x: Int, y: Int) { + val g2 = g.create() as Graphics2D + try { + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + g2.translate(x, y) + g2.color = bg + g2.fillRoundRect(0, 0, iconWidth, iconHeight, iconHeight, iconHeight) + g2.color = fg + g2.font = JBFont.small() + val fm = g2.fontMetrics + val base = (iconHeight + fm.ascent - fm.descent) / 2 + g2.drawString(text, UiStyle.Gap.lg(), base) + } finally { + g2.dispose() + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/PickerButton.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/PickerButton.kt index 6a08c5bb9ce..86b1b6763ef 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/PickerButton.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/PickerButton.kt @@ -1,22 +1,19 @@ package ai.kilocode.client.ui -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.Graphics import java.awt.Graphics2D import java.awt.RenderingHints import java.awt.event.MouseAdapter import java.awt.event.MouseEvent -import javax.swing.UIManager open class PickerButton : JBLabel() { private var over = false init { border = pickerBorder() - background = picker() + background = UiStyle.Colors.picker() // The custom rounded fill needs parent background around the corners. isOpaque = false addMouseListener(object : MouseAdapter() { @@ -33,14 +30,14 @@ open class PickerButton : JBLabel() { override fun updateUI() { super.updateUI() border = pickerBorder() - background = picker() + background = UiStyle.Colors.picker() } override fun paintComponent(g: Graphics) { val g2 = g.create() as Graphics2D try { g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) - g2.color = if (isEnabled && over) JBUI.CurrentTheme.ActionButton.hoverBackground() else picker() + g2.color = if (isEnabled && over) JBUI.CurrentTheme.ActionButton.hoverBackground() else UiStyle.Colors.picker() val arc = JBUI.scale(JBUI.getInt("Button.arc", 6)) g2.fillRoundRect(0, 0, width, height, arc, arc) } finally { @@ -55,11 +52,5 @@ open class PickerButton : JBLabel() { repaint() } - private fun picker() = JBColor.lazy { - UIManager.getColor("ComboBoxButton.background") - ?: UIManager.getColor("ComboBox.nonEditableBackground") - ?: UIUtil.getPanelBackground() - } - private fun pickerBorder() = JBUI.Borders.empty(UiStyle.Gap.xs(), UiStyle.Gap.lg()) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/ProfileCardPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/RoundedContentPanel.kt similarity index 50% rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/ProfileCardPanel.kt rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/RoundedContentPanel.kt index 17f57560260..ae1d5dde543 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/ProfileCardPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/RoundedContentPanel.kt @@ -1,13 +1,13 @@ -package ai.kilocode.client.settings.profile +package ai.kilocode.client.ui -import ai.kilocode.client.ui.UiStyle import com.intellij.util.ui.JBUI import com.intellij.util.ui.components.BorderLayoutPanel +import java.awt.Color import java.awt.Graphics import java.awt.Graphics2D import java.awt.RenderingHints -internal class ProfileCardPanel( +open class RoundedContentPanel( top: Int, left: Int, bottom: Int = top, @@ -16,14 +16,14 @@ internal class ProfileCardPanel( init { isOpaque = false - background = UiStyle.Colors.cardBg() + background = contentColor() border = JBUI.Borders.empty(top, left, bottom, right) } override fun updateUI() { super.updateUI() isOpaque = false - background = UiStyle.Colors.cardBg() + background = contentColor() } override fun paintComponent(g: Graphics) { @@ -33,14 +33,29 @@ internal class ProfileCardPanel( RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON, ) - val arc = UiStyle.Arc.component() - g2.color = UiStyle.Colors.cardBg() + val arc = cornerArc() + g2.color = contentColor() g2.fillRoundRect(0, 0, width, height, arc, arc) - g2.color = UiStyle.Colors.cardBorder() - g2.drawRoundRect(0, 0, width - 1, height - 1, arc, arc) + val line = outlineColor() + if (line != null) { + g2.color = line + for (idx in 0 until outlineWidth()) { + val w = width - idx * 2 - 1 + val h = height - idx * 2 - 1 + if (w > 0 && h > 0) g2.drawRoundRect(idx, idx, w, h, arc, arc) + } + } } finally { g2.dispose() } super.paintComponent(g) } + + protected open fun contentColor(): Color = UiStyle.Colors.cardBg() + + protected open fun outlineColor(): Color? = UiStyle.Colors.cardBorder() + + protected open fun outlineWidth(): Int = JBUI.scale(1) + + protected open fun cornerArc(): Int = UiStyle.Arc.component() } 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 3de8452eae0..4d5f3265f7a 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 @@ -53,9 +53,36 @@ object UiStyle { UIManager.getColor("TextField.background") ?: UIUtil.getPanelBackground() } + /** Standard picker/combobox surface, contrasted against the default panel background by the active theme. */ + fun picker(): Color = JBColor.lazy { + UIManager.getColor("ComboBoxButton.background") + ?: UIManager.getColor("ComboBox.nonEditableBackground") + ?: UIUtil.getPanelBackground() + } + + /** Filled badge surface using platform badge/info colors with a soft theme-derived fallback. */ + fun badgeBg(): Color = JBColor.lazy { + UIManager.getColor("Badge.background") + ?: UIManager.getColor("Label.infoBackground") + ?: blend(cardBg(), fg(), 0.16f) + } + + /** Filled badge text color paired with [badgeBg]. */ + fun badgeFg(): Color = JBColor(Color.BLACK, UIUtil.getLabelForeground()) + /** Card border color shared across profile cards. */ fun cardBorder(): Color = JBColor.namedColor("Component.borderColor", JBColor.border()) + /** + * Floating panel background: white in light themes, black in dark themes. + * Used for account switcher popup panels and any overlay panels that need + * a high-contrast base distinct from the standard editor/sidebar background. + */ + fun floatingPanel(): Color = JBColor.namedColor( + "Kilo.FloatingPanel.background", + JBColor(java.awt.Color.WHITE, java.awt.Color.BLACK), + ) + fun errorLabelForeground(): Color = JBColor.namedColor("Label.errorForeground", UIUtil.getErrorForeground()) fun warningLabelForeground(): Color = JBColor.lazy { 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 ab31f63bbca..e81aff2ee98 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -6,6 +6,8 @@ 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. +session.account.balance=Balance: {0} +session.account.switcher=Switch account session.empty.loading=Loading... session.empty.recent=RECENT session.showHistory=Show History 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 1e8beed18a9..4fb7c6f6904 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,10 +11,13 @@ import ai.kilocode.client.session.ui.ConnectionPanel import ai.kilocode.client.session.ui.EmptySessionPanel import ai.kilocode.client.session.ui.LoadingPanel import ai.kilocode.client.session.ui.prompt.PromptPanel +import ai.kilocode.client.session.ui.account.SessionAccountOverlay import ai.kilocode.client.session.ui.SessionMessageListPanel import ai.kilocode.client.session.ui.SessionRootPanel import ai.kilocode.client.session.ui.header.SessionHeaderPanel +import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.controller.SessionControllerEvent +import com.intellij.util.ui.JBUI import ai.kilocode.client.session.views.PermissionView import ai.kilocode.client.session.views.question.QuestionView import ai.kilocode.rpc.dto.MessageWithPartsDto @@ -42,7 +45,7 @@ class SessionUiLayoutTest : SessionUiTestBase() { assertSame(root.content, stack.parent) assertSame(stack, connection.parent) - assertEquals(1, root.overlay.componentCount) + assertTrue(root.overlay.components.any { it is SessionAccountOverlay }) assertEquals(listOf(connection, prompt), stack.components.toList()) } @@ -349,4 +352,76 @@ class SessionUiLayoutTest : SessionUiTestBase() { meta = PermissionMeta(raw = emptyMap()), ) ) + + // --- account overlay layout tests --- + + fun `test account overlay is registered in root overlay layer`() { + val root = find(ui) + val overlay = find(ui) + + assertSame(root.overlay, overlay.parent) + } + + fun `test account overlay hidden before recents complete`() { + rpc.recentGate = kotlinx.coroutines.CompletableDeferred() + rpc.recent.add(session("ses_1")) + ui = newUi(displayMs = 1_000) + + settleShort(100) + + val overlay = find(ui) + assertFalse(overlay.isVisible) + + rpc.recentGate!!.complete(Unit) + } + + fun `test account overlay shows after recents complete`() { + rpc.recent.add(session("ses_1")) + ui = newUi(displayMs = 1_000) + + settle() + + val overlay = find(ui) + assertTrue(overlay.isVisible) + } + + fun `test account overlay hides after first prompt`() { + rpc.recent.add(session("ses_1")) + ui = newUi(displayMs = 1_000) + settle() + + val overlay = find(ui) + assertTrue(overlay.isVisible) + + com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait { + controller().prompt("hello") + } + settle() + + assertFalse(overlay.isVisible) + } + + fun `test explicit session does not show overlay`() { + ui = newUi(id = "ses_test") + settle() + + val overlay = find(ui) + assertFalse(overlay.isVisible) + } + + fun `test account overlay uses prompt panel top and right insets`() { + rpc.recent.add(session("ses_1")) + ui = newUi(displayMs = 1_000) + settle() + layout() + + val root = find(ui) + val overlay = find(ui) + val top = JBUI.scale(SessionUiStyle.View.Prompt.PANEL_VERTICAL_PADDING) + val right = JBUI.scale(SessionUiStyle.View.Prompt.PANEL_HORIZONTAL_PADDING) + + assertTrue(overlay.isVisible) + assertEquals(top, overlay.y) + assertEquals(root.overlay.width - overlay.width - right, overlay.x) + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ViewSwitchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ViewSwitchingTest.kt index ae2a2f2ea72..fecf38da614 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ViewSwitchingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ViewSwitchingTest.kt @@ -2,6 +2,11 @@ package ai.kilocode.client.session.controller import ai.kilocode.client.session.SessionRef import ai.kilocode.client.session.model.SessionState +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.ProfileBalanceDto +import ai.kilocode.rpc.dto.ProfileDto +import ai.kilocode.rpc.dto.ProfileOrganizationDto import kotlinx.coroutines.CompletableDeferred class ViewSwitchingTest : SessionControllerTestBase() { @@ -62,6 +67,8 @@ class ViewSwitchingTest : SessionControllerTestBase() { assertTrue(rpc.recentCalls.contains("/test" to SessionController.RECENT_LIMIT)) assertControllerEvents(""" + AccountOverlayChanged hide + AccountOverlayChanged show loggedIn=false AppChanged WorkspaceChanged WorkspaceReady @@ -79,6 +86,8 @@ class ViewSwitchingTest : SessionControllerTestBase() { assertTrue(rpc.recentCalls.contains("/test" to SessionController.RECENT_LIMIT)) assertControllerEvents(""" + AccountOverlayChanged hide + AccountOverlayChanged show loggedIn=false AppChanged WorkspaceChanged WorkspaceReady @@ -95,6 +104,7 @@ class ViewSwitchingTest : SessionControllerTestBase() { assertTrue(rpc.recentCalls.isEmpty()) assertControllerEvents(""" + AccountOverlayChanged hide AppChanged WorkspaceChanged ViewChanged progress @@ -347,4 +357,133 @@ class ViewSwitchingTest : SessionControllerTestBase() { version = "1", time = ai.kilocode.rpc.dto.SessionTimeDto(created = 1.0, updated = 2.0), ) + + // --- account overlay controller tests --- + + fun `test empty session with workspace ready emits account overlay show`() { + projectRpc.state.value = workspaceReady() + rpc.recent.add(session("ses_1")) + val m = controller() + val events = collect(m) + + flush() + + assertTrue(events.any { it is SessionControllerEvent.AccountOverlayChanged.Show }) + val show = events.filterIsInstance().last() + assertEquals("AccountOverlayChanged show loggedIn=false", show.toString()) + } + + fun `test empty session overlay show includes logged in profile`() { + projectRpc.state.value = workspaceReady() + rpc.recent.add(session("ses_1")) + val prof = ProfileDto( + email = "user@example.com", + name = "Test User", + balance = ProfileBalanceDto(10.0), + ) + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = prof) + val m = controller() + val events = collect(m) + + flush() + + val show = events.filterIsInstance().last() + assertEquals("AccountOverlayChanged show loggedIn=true", show.toString()) + assertEquals(prof.email, show.account.profile?.email) + } + + fun `test first prompt hides overlay`() { + projectRpc.state.value = workspaceReady() + rpc.recent.add(session("ses_1")) + val m = controller() + flush() + val events = collect(m) + + edt { m.prompt("hello") } + flush() + + assertTrue(events.any { it is SessionControllerEvent.AccountOverlayChanged.Hide }) + assertFalse(events.filterIsInstance().any { it.account.profile != null }) + } + + fun `test explicit local session load never shows overlay`() { + projectRpc.state.value = workspaceReady() + rpc.recent.add(session("ses_1")) + val m = controller("ses_test") + val events = collect(m) + + flush() + + assertFalse(events.any { it is SessionControllerEvent.AccountOverlayChanged.Show }) + } + + fun `test explicit cloud import never shows overlay`() { + projectRpc.state.value = workspaceReady() + rpc.importedCloudSession = session("ses_imported") + rpc.recent.add(session("ses_1")) + val m = controller("cloud:cloud_1") + val events = collect(m) + + flush() + + assertFalse(events.any { it is SessionControllerEvent.AccountOverlayChanged.Show }) + } + + fun `test app profile change refreshes overlay while allowed`() { + projectRpc.state.value = workspaceReady() + rpc.recent.add(session("ses_1")) + val m = controller() + val events = collect(m) + flush() + + val prof = ProfileDto(email = "user@example.com", balance = ProfileBalanceDto(20.0)) + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = prof) + flush() + + val shows = events.filterIsInstance() + assertTrue(shows.isNotEmpty()) + assertTrue(shows.last().account.profile?.email == "user@example.com") + } + + fun `test selecting personal account emits switching overlay`() { + projectRpc.state.value = workspaceReady() + rpc.recent.add(session("ses_1")) + appRpc.state.value = KiloAppStateDto( + KiloAppStatusDto.READY, + profile = ProfileDto( + email = "user@example.com", + currentOrgId = "org_1", + organizations = listOf(ProfileOrganizationDto("org_1", "Kilo", "OWNER")), + ), + ) + val m = controller() + val events = collect(m) + flush() + events.clear() + + edt { m.selectOrganization(null) } + flush() + + val show = events.filterIsInstance() + .first { it.account.switching } + assertTrue(show.account.switching) + assertNull(show.account.targetOrgId) + assertEquals(null, appRpc.orgSelections.last()) + } + + fun `test replay includes current overlay event`() { + projectRpc.state.value = workspaceReady() + rpc.recent.add(session("ses_1")) + val m = controller() + flush() + + // Add a new listener after initial events are done + val replayed = collect(m) + + assertTrue(replayed.any { it is SessionControllerEvent.AccountOverlayChanged.Show }) + } + + fun `test overlay hide event has correct string`() { + assertEquals("AccountOverlayChanged hide", SessionControllerEvent.AccountOverlayChanged.Hide.toString()) + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/account/SessionAccountOverlayTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/account/SessionAccountOverlayTest.kt new file mode 100644 index 00000000000..379e4222656 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/account/SessionAccountOverlayTest.kt @@ -0,0 +1,320 @@ +package ai.kilocode.client.session.ui.account + +import ai.kilocode.client.session.controller.SessionControllerEvent +import ai.kilocode.client.session.controller.SessionControllerEvent.AccountOverlaySnapshot +import ai.kilocode.client.session.controller.SessionControllerTestBase +import ai.kilocode.client.ui.FilledBadgeIcon +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.ProfileBalanceDto +import ai.kilocode.rpc.dto.ProfileDto +import ai.kilocode.rpc.dto.ProfileOrganizationDto +import com.intellij.icons.AllIcons + +@Suppress("UnstableApiUsage") +class SessionAccountOverlayTest : SessionControllerTestBase() { + + private lateinit var panel: SessionAccountOverlay + private val selected = mutableListOf() + private var loginCalls = 0 + private var profileCalls = 0 + + override fun setUp() { + super.setUp() + panel = SessionAccountOverlay( + select = { org -> selected.add(org) }, + login = { loginCalls++ }, + profile = { profileCalls++ }, + ) + } + + private fun show(snap: AccountOverlaySnapshot) { + edt { panel.onEvent(SessionControllerEvent.AccountOverlayChanged.Show(snap)) } + } + + private fun hide() { + edt { panel.onEvent(SessionControllerEvent.AccountOverlayChanged.Hide) } + } + + private fun snap(profile: ProfileDto?) = + AccountOverlaySnapshot(status = KiloAppStatusDto.READY, profile = profile) + + private fun profile( + email: String = "user@example.com", + name: String? = null, + organizations: List = emptyList(), + balance: ProfileBalanceDto? = null, + currentOrgId: String? = null, + ) = ProfileDto( + email = email, + name = name, + organizations = organizations, + balance = balance, + currentOrgId = currentOrgId, + ) + + private fun org(id: String, name: String, role: String = "MEMBER") = + ProfileOrganizationDto(id = id, name = name, role = role) + + // --- test 1: logged-out state shows login prompt --- + + fun `test logged out state is visible with login button`() { + show(snap(null)) + + assertTrue(panel.isVisible) + assertTrue(panel.loggedOutVisible()) + } + + // --- test 2: logged-in personal account shows picker title --- + + fun `test logged in personal account shows picker title`() { + val prof = profile( + email = "user@example.com", + name = "Test User", + balance = ProfileBalanceDto(10.0), + ) + show(snap(prof)) + + assertTrue(panel.isVisible) + assertTrue(panel.loggedInVisible()) + assertTrue(panel.pickerVisible()) + assertEquals("Personal Account", panel.accountTitle()) + } + + fun `test logged in with email fallback still shows personal account title`() { + val prof = profile(email = "user@example.com") + show(snap(prof)) + + assertEquals("Personal Account", panel.accountTitle()) + } + + // --- test 3: logged-in org account shows org title in picker --- + + fun `test logged in org account shows org title in picker`() { + val acme = org("org_1", "Acme", "MEMBER") + val prof = profile( + email = "user@example.com", + organizations = listOf(acme), + balance = ProfileBalanceDto(25.0), + currentOrgId = "org_1", + ) + show(snap(prof)) + + assertTrue(panel.isVisible) + assertTrue(panel.loggedInVisible()) + assertTrue(panel.pickerVisible()) + assertEquals("Acme", panel.accountTitle()) + // personal + acme = 2 choices + assertEquals(2, panel.choiceCount()) + // selected index is 1 (org_1 is the second item) + assertEquals(1, panel.selectedIndex()) + } + + // --- test 4: programmatic update does not call select callback --- + + fun `test programmatic update does not call select callback`() { + val acme = org("org_1", "Acme") + val prof = profile( + email = "user@example.com", + organizations = listOf(acme), + currentOrgId = null, + ) + // Show with personal account selected + show(snap(prof)) + selected.clear() + + // Show again with same profile - no user selection + show(snap(prof)) + + assertEquals(0, selected.size) + } + + // --- test 5: switching disables picker --- + + fun `test switching true disables picker`() { + val acme = org("org_1", "Acme") + val prof = profile( + email = "user@example.com", + organizations = listOf(acme), + currentOrgId = null, + ) + val switchingSnap = AccountOverlaySnapshot( + status = KiloAppStatusDto.READY, + profile = prof, + switching = true, + targetOrgId = "org_1", + ) + show(switchingSnap) + + assertFalse(panel.pickerEnabled()) + } + + fun `test switching false enables picker`() { + val acme = org("org_1", "Acme") + val prof = profile( + email = "user@example.com", + organizations = listOf(acme), + currentOrgId = null, + ) + show(snap(prof)) + + assertTrue(panel.pickerEnabled()) + } + + // --- test 6: switching with targetOrgId shows the target account title --- + + fun `test switching with targetOrgId shows target account title`() { + val acme = org("org_1", "Acme") + val prof = profile( + email = "user@example.com", + organizations = listOf(acme), + currentOrgId = null, + ) + val switchingSnap = AccountOverlaySnapshot( + status = KiloAppStatusDto.READY, + profile = prof, + switching = true, + targetOrgId = "org_1", + ) + show(switchingSnap) + + // Should display the target org while switching + assertEquals("Acme", panel.accountTitle()) + assertFalse(panel.pickerEnabled()) + } + + fun `test switching to personal account shows personal account title`() { + val acme = org("org_1", "Acme") + val prof = profile( + email = "user@example.com", + organizations = listOf(acme), + currentOrgId = "org_1", + ) + val switchingSnap = AccountOverlaySnapshot( + status = KiloAppStatusDto.READY, + profile = prof, + switching = true, + targetOrgId = null, + ) + show(switchingSnap) + + assertEquals("Personal Account", panel.accountTitle()) + assertFalse(panel.pickerEnabled()) + } + + fun `test account switcher uses card background and border`() { + val prof = profile(email = "user@example.com") + show(snap(prof)) + + assertEquals(UiStyle.Colors.cardBg(), panel.panelBackground()) + assertEquals(UiStyle.Colors.cardBorder(), panel.panelBorderColor()) + } + + // --- test 7: transient null profile keeps existing logged-in content --- + + fun `test transient null profile keeps logged in card`() { + val prof = profile(email = "user@example.com", name = "Test User") + show(snap(prof)) + assertTrue(panel.loggedInVisible()) + assertEquals("Personal Account", panel.accountTitle()) + + // Show transient null (pending switch) + val transientSnap = AccountOverlaySnapshot( + status = KiloAppStatusDto.READY, + profile = null, + transient = true, + ) + show(transientSnap) + + // Should remain visible and logged-in, not flash to logged-out + assertTrue(panel.isVisible) + assertTrue(panel.loggedInVisible()) + } + + // --- test 8: hide event hides component --- + + fun `test hide event hides component`() { + val prof = profile(email = "user@example.com") + show(snap(prof)) + assertTrue(panel.isVisible) + + hide() + + assertFalse(panel.isVisible) + } + + // --- test 9: renderer uses check icon for active account --- + + fun `test renderer active account uses check icon`() { + val choice = AccountChoice("org_1", "Acme") + val renderer = AccountPickerRenderer { "org_1" } + + assertSame(AccountPickerRenderer.checked, renderer.icon(choice)) + } + + // --- test 10: renderer uses empty icon for inactive account --- + + fun `test renderer inactive account reserves icon space`() { + val choice = AccountChoice(null, "Personal Account") + val renderer = AccountPickerRenderer { "org_1" } + + assertSame(AccountPickerRenderer.empty, renderer.icon(choice)) + assertEquals(AllIcons.Actions.Checked.iconWidth, renderer.icon(choice).iconWidth) + } + + // --- test 11: balance badge appears when profile has balance --- + + fun `test logged in account shows balance badge`() { + val prof = profile(balance = ProfileBalanceDto(10.0)) + show(snap(prof)) + + assertTrue(panel.balanceVisible()) + assertTrue(panel.balanceIcon() is FilledBadgeIcon) + assertEquals("\$10.00", panel.balanceText()) + } + + // --- test 12: balance badge hides when balance is missing --- + + fun `test logged in account hides balance badge without balance`() { + show(snap(profile(balance = null))) + + assertFalse(panel.balanceVisible()) + assertNull(panel.balanceIcon()) + } + + // --- test 13: balance badge updates when profile balance changes --- + + fun `test balance badge updates retained label`() { + show(snap(profile(balance = ProfileBalanceDto(10.0)))) + assertEquals("\$10.00", panel.balanceText()) + + show(snap(profile(balance = ProfileBalanceDto(25.0)))) + + assertTrue(panel.balanceVisible()) + assertEquals("\$25.00", panel.balanceText()) + } + + // --- test 14: profile button uses toolbar icon and invokes callback --- + + fun `test profile button uses profile icon and opens settings`() { + show(snap(profile(email = "user@example.com"))) + + assertSame(AllIcons.General.User, panel.profileIcon()) + panel.clickProfile() + + assertEquals(1, profileCalls) + } + + // --- test 15: transient null profile keeps logged in balance badge --- + + fun `test transient null profile keeps logged in balance badge`() { + show(snap(profile(balance = ProfileBalanceDto(10.0)))) + val icon = panel.balanceIcon() + + show(AccountOverlaySnapshot(status = KiloAppStatusDto.READY, profile = null, transient = true)) + + assertTrue(panel.loggedInVisible()) + assertTrue(panel.balanceVisible()) + assertSame(icon, panel.balanceIcon()) + } +}