feat(desktop): polish JetBrains account switcher

This commit is contained in:
kirillk
2026-05-19 13:23:45 -04:00
parent 0a358a56a5
commit 09572d3d47
19 changed files with 1249 additions and 105 deletions
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Show account login, switching, and balance controls on the empty JetBrains session screen.
@@ -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() {}
}
@@ -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)
}
}
@@ -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"
@@ -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
}
@@ -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<AccountChoice> {
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<out AccountChoice>,
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
}
@@ -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<AccountChoice> = 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()
}
@@ -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()
}
}
}
}
@@ -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))
}
}
@@ -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 {
@@ -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) {
@@ -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()
}
}
}
@@ -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())
}
@@ -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()
}
@@ -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 {
@@ -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
@@ -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<SessionRootPanel>(ui)
val overlay = find<SessionAccountOverlay>(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<SessionAccountOverlay>(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<SessionAccountOverlay>(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<SessionAccountOverlay>(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<SessionAccountOverlay>(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<SessionRootPanel>(ui)
val overlay = find<SessionAccountOverlay>(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)
}
}
@@ -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<SessionControllerEvent.AccountOverlayChanged.Show>().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<SessionControllerEvent.AccountOverlayChanged.Show>().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<SessionControllerEvent.AccountOverlayChanged.Show>().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<SessionControllerEvent.AccountOverlayChanged.Show>()
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<SessionControllerEvent.AccountOverlayChanged.Show>()
.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())
}
}
@@ -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<String?>()
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<ProfileOrganizationDto> = 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())
}
}