diff --git a/.changeset/jetbrains-profile-polish.md b/.changeset/jetbrains-profile-polish.md new file mode 100644 index 00000000000..5af1f098434 --- /dev/null +++ b/.changeset/jetbrains-profile-polish.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Polish the JetBrains user profile settings layout with a compact account stack, copyable email, simplified organization names, and a refreshable balance card. 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 a03828a1bf6..17c9dc57b10 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 @@ -3,19 +3,24 @@ package ai.kilocode.client.settings.profile import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.ui.UiStyle import ai.kilocode.rpc.dto.ProfileDto +import com.intellij.icons.AllIcons +import com.intellij.ui.JBColor +import com.intellij.ui.RoundedLineBorder import com.intellij.ui.components.JBLabel -import com.intellij.ui.dsl.builder.AlignX -import com.intellij.ui.dsl.builder.BottomGap -import com.intellij.ui.dsl.builder.RightGap -import com.intellij.ui.dsl.builder.TopGap -import com.intellij.ui.dsl.builder.panel +import com.intellij.util.ui.JBFont +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.JBValue import java.awt.BorderLayout import java.awt.Font +import java.awt.GridBagConstraints +import java.awt.GridBagLayout import java.text.DecimalFormat +import javax.swing.Box import javax.swing.DefaultComboBoxModel import javax.swing.JButton import javax.swing.JComboBox import javax.swing.JPanel +import javax.swing.SwingConstants /** * Retained logged-in UI. Labels, combo box, and buttons are built once and @@ -25,48 +30,76 @@ internal class LoggedInProfileUi( private val dashboard: () -> Unit, private val logout: () -> Unit, private val organization: (String?) -> Unit, + private val refresh: () -> Unit, ) : JPanel(BorderLayout()) { private val nameLabel = JBLabel().apply { font = font.deriveFont(Font.BOLD) } - private val emailLabel = JBLabel().apply { foreground = UiStyle.Colors.weak() } + private val emailLabel = JBLabel().apply { + foreground = UiStyle.Colors.weak() + setCopyable(true) + } - private val balanceLabel = JBLabel().apply { font = font.deriveFont(Font.BOLD) } - private val balanceContainer = panel { - row { - label(KiloBundle.message("profile.balance.title")).gap(RightGap.SMALL) - cell(balanceLabel) + private val titleLabel = JBLabel(KiloBundle.message("profile.balance.title")).apply { + foreground = UiStyle.Colors.weak() + } + private val valueLabel = JBLabel().apply { + horizontalAlignment = SwingConstants.CENTER + font = JBFont.h1().asBold() + } + private val refreshBtn = JButton(KiloBundle.message("profile.action.refresh"), AllIcons.Actions.Refresh) + .also { + it.addActionListener { + if (refreshing) return@addActionListener + setRefreshing(true) + refresh() + } } + + private val balanceCard = JPanel(BorderLayout()).apply { + border = JBUI.Borders.compound( + RoundedLineBorder(JBColor.border(), JBValue.UIInteger("Component.arc", 8).get()), + JBUI.Borders.empty(JBUI.scale(12), JBUI.scale(16)), + ) + add(titleLabel, BorderLayout.NORTH) + add(JPanel(GridBagLayout()).apply { + add(valueLabel, GridBagConstraints().apply { + gridx = 0 + gridy = 0 + anchor = GridBagConstraints.CENTER + }) + add(refreshBtn, GridBagConstraints().apply { + gridx = 0 + gridy = 1 + anchor = GridBagConstraints.CENTER + }) + }, BorderLayout.CENTER) } private val comboModel = DefaultComboBoxModel() val combo = JComboBox(comboModel) - private val orgContainer = panel { - group(KiloBundle.message("profile.group.organization")) { - row(KiloBundle.message("profile.label.account")) { - cell(combo).align(AlignX.FILL) - } - } - } val dashboardBtn = JButton(KiloBundle.message("profile.action.dashboard")) .also { it.addActionListener { dashboard() } } val logoutBtn = JButton(KiloBundle.message("profile.action.logout")) .also { it.addActionListener { logout() } } - private val content = panel { - group(KiloBundle.message("profile.group.account")) { - row { cell(nameLabel) } - row { cell(emailLabel) } - } - row { cell(balanceContainer) }.topGap(TopGap.SMALL) - row { cell(orgContainer) } - row { - cell(dashboardBtn).gap(RightGap.SMALL) - cell(logoutBtn) - }.bottomGap(BottomGap.SMALL) + private val buttons = JPanel().apply { + layout = javax.swing.BoxLayout(this, javax.swing.BoxLayout.X_AXIS) + add(dashboardBtn) + add(Box.createHorizontalStrut(JBUI.scale(6))) + add(logoutBtn) + } + + private val content = JPanel(GridBagLayout()).apply { + addRow(nameLabel, 0) + addRow(emailLabel, 1, UiStyle.Gap.lg()) + addRow(combo, 2, UiStyle.Gap.lg()) + addRow(balanceCard, 3, UiStyle.Gap.lg()) + addRow(buttons, 4, UiStyle.Gap.lg()) } private var applying = false + private var refreshing = false private var currentProf: ProfileDto? = null init { @@ -83,6 +116,17 @@ internal class LoggedInProfileUi( add(content, BorderLayout.NORTH) } + private fun JPanel.addRow(comp: java.awt.Component, y: Int, top: Int = 0) { + add(comp, GridBagConstraints().apply { + gridx = 0 + gridy = y + weightx = 1.0 + fill = GridBagConstraints.HORIZONTAL + anchor = GridBagConstraints.WEST + insets = JBUI.insets(top, 0, 0, 0) + }) + } + fun update(profile: ProfileDto) { currentProf = profile @@ -94,22 +138,52 @@ internal class LoggedInProfileUi( if (showEmail && emailLabel.text != profile.email) emailLabel.text = profile.email val bal = profile.balance + var changed = false if (bal != null) { val fmt = DecimalFormat("$#,##0.00") val balText = fmt.format(bal.balance) - if (balanceLabel.text != balText) balanceLabel.text = balText - balanceContainer.isVisible = true + if (valueLabel.text != balText) { + valueLabel.text = balText + changed = true + } + if (!balanceCard.isVisible) { + balanceCard.isVisible = true + changed = true + } } else { - balanceContainer.isVisible = false + if (balanceCard.isVisible) { + balanceCard.isVisible = false + changed = true + } } applyOrganizations(profile) + if (changed) syncLayout() + } + + fun setRefreshing(refreshing: Boolean) { + this.refreshing = refreshing + val text = if (refreshing) { + KiloBundle.message("profile.action.refreshing") + } else { + KiloBundle.message("profile.action.refresh") + } + if (refreshBtn.text != text) refreshBtn.text = text + refreshBtn.maximumSize = refreshBtn.preferredSize + syncLayout() + } + + private fun syncLayout() { + balanceCard.revalidate() + content.revalidate() + revalidate() + repaint() } private fun applyOrganizations(profile: ProfileDto) { val orgs = profile.organizations val options = listOf(KiloBundle.message("profile.personalAccount")) + - orgs.map { "${it.name} (${it.role.lowercase()})" } + orgs.map { it.name } val target = profile.currentOrgId ?.let { id -> orgs.indexOfFirst { it.id == id }.takeIf { it >= 0 }?.plus(1) } @@ -128,8 +202,8 @@ internal class LoggedInProfileUi( } val show = orgs.isNotEmpty() - if (orgContainer.isVisible != show) { - orgContainer.isVisible = show + if (combo.isVisible != show) { + combo.isVisible = show revalidate() repaint() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/ProfileUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/ProfileUi.kt index c01f450d5e3..d0d7674aeb1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/ProfileUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/ProfileUi.kt @@ -52,6 +52,7 @@ internal class ProfileUi( dashboard = { browse(DASHBOARD_URL) }, logout = ::logout, organization = ::organization, + refresh = ::refreshProfile, ) private var prof = profile @@ -186,6 +187,26 @@ internal class ProfileUi( } } + private fun refreshProfile() { + cs.launch { + try { + val profile = app.refreshProfile() + val state = app.state.value + withContext(edt) { + update(profile ?: state.profile, state.status) + account.setRefreshing(false) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + withContext(edt) { + applyState() + account.setRefreshing(false) + } + } + } + } + private fun cancel() { auth = null sync() 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 d7e5f42f979..8679774e69a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -132,13 +132,15 @@ profile.label.account=Active account: profile.notLoggedIn=Not logged in profile.status.connecting=Connecting to Kilo... profile.status.error=Connection error -profile.balance.title=Balance: +profile.balance.title=BALANCE profile.personalAccount=Personal Account profile.switchingAccount=Switching account... profile.action.login=Login with Kilo Code profile.action.logout=Log Out profile.action.dashboard=Dashboard profile.action.retry=Retry +profile.action.refresh=Refresh +profile.action.refreshing=Refreshing.... profile.login.signingIn=Signing in to Kilo Code profile.login.urlLabel=Open this URL: profile.login.codeLabel=Enter this code: diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/UserProfileConfigurableTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/UserProfileConfigurableTest.kt index 4154ede483a..2ae1fdb44ae 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/UserProfileConfigurableTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/UserProfileConfigurableTest.kt @@ -10,6 +10,7 @@ import ai.kilocode.rpc.dto.ProfileDto import ai.kilocode.rpc.dto.ProfileOrganizationDto import com.intellij.openapi.application.ApplicationManager import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.JBLabel import com.intellij.util.ui.UIUtil import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob @@ -20,7 +21,9 @@ import java.awt.Component import java.awt.Container import javax.swing.AbstractButton import javax.swing.JComboBox +import javax.swing.JEditorPane import javax.swing.JLabel +import javax.swing.SwingUtilities @Suppress("UnstableApiUsage") class UserProfileConfigurableTest : BasePlatformTestCase() { @@ -110,7 +113,10 @@ class UserProfileConfigurableTest : BasePlatformTestCase() { edt { val t = text(panel) assertTrue(t, t.contains("\$10.00")) - combos(panel).single().selectedIndex = 1 + val combo = combos(panel).single() + assertEquals("Acme", combo.getItemAt(1)) + assertFalse(combo.getItemAt(1).toString().contains("admin", ignoreCase = true)) + combo.selectedIndex = 1 } flush() @@ -121,6 +127,67 @@ class UserProfileConfigurableTest : BasePlatformTestCase() { assertEquals(listOf("org_1"), rpc.orgSelections) } + fun `test logged in profile uses compact stack and copyable email`() { + val profile = ProfileDto( + email = "alice@test.com", + name = "Alice", + organizations = listOf(ProfileOrganizationDto(id = "org_1", name = "Acme", role = "MEMBER")), + balance = ProfileBalanceDto(10.0), + ) + app._state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = profile) + edt { panel.update(profile, KiloAppStatusDto.READY) } + + edt { + val t = text(panel) + assertTrue(t, t.contains("Alice")) + assertTrue(t, t.contains("alice@test.com")) + assertTrue(t, t.contains("BALANCE")) + assertTrue(t, t.contains("Refresh")) + assertFalse(t, t.contains("Active account")) + assertFalse(t, t.contains("Organization")) + + val mail = labels(panel).filterIsInstance().first { it.text == "alice@test.com" } + assertTrue(editorPanes(mail).isNotEmpty()) + + panel.setSize(800, 600) + layout(panel) + val refresh = buttons(panel).first { it.text == "Refresh" } + val card = refresh.parent + val dash = buttons(panel).first { it.text == "Dashboard" } + val cardLoc = SwingUtilities.convertPoint(card.parent, card.location, panel) + val dashLoc = SwingUtilities.convertPoint(dash.parent, dash.location, panel) + assertTrue(dashLoc.y >= cardLoc.y + card.height) + } + } + + fun `test refresh updates balance UI`() { + val profile = ProfileDto( + email = "alice@test.com", + name = "Alice", + balance = ProfileBalanceDto(10.0), + ) + val updated = profile.copy(balance = ProfileBalanceDto(25.0)) + rpc.fakeProfile = profile + app._state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = profile) + edt { panel.update(profile, KiloAppStatusDto.READY) } + + edt { + assertTrue(text(panel).contains("\$10.00")) + rpc.fakeProfile = updated + buttons(panel).first { it.text == "Refresh" }.doClick() + assertTrue(text(panel).contains("Refreshing....")) + } + flush() + + edt { + val t = text(panel) + assertTrue(t, t.contains("\$25.00")) + assertTrue(t, t.contains("Refresh")) + assertFalse(t, t.contains("Refreshing....")) + assertTrue(buttons(panel).first { it.text == "Refresh" }.isEnabled) + } + } + fun `test logged out update retains login button`() { edt { val btn = buttons(panel).first { it.text == "Login with Kilo Code" } @@ -252,6 +319,21 @@ class UserProfileConfigurableTest : BasePlatformTestCase() { } } + private fun layout(root: Container) { + root.doLayout() + for (comp in root.components) { + if (comp is Container) layout(comp) + } + } + + private fun editorPanes(root: Container): List = buildList { + for (comp in root.components) { + if (!comp.isVisible) continue + if (comp is JEditorPane) add(comp) + if (comp is Container) addAll(editorPanes(comp)) + } + } + private fun text(root: Container): String { val acc = mutableListOf() collectText(root, acc) @@ -263,6 +345,7 @@ class UserProfileConfigurableTest : BasePlatformTestCase() { if (!comp.isVisible) continue when (comp) { is AbstractButton -> comp.text?.let { acc.add(it) } + is JEditorPane -> comp.text?.let { acc.add(it) } is JLabel -> comp.text?.let { acc.add(it) } } if (comp is Container) collectText(comp, acc)