diff --git a/.changeset/jetbrains-empty-panel-layout.md b/.changeset/jetbrains-empty-panel-layout.md new file mode 100644 index 00000000000..3f13739eeb8 --- /dev/null +++ b/.changeset/jetbrains-empty-panel-layout.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Center description text and remove the fixed-width cap from the empty session panel so recent sessions can stretch naturally. 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 a73b7f2038e..91a9e18ed4a 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 @@ -219,7 +219,7 @@ class SessionUi( is SessionControllerEvent.ViewChanged.ShowRecents -> { val panel = EmptySessionPanel(this, controller, event.recents) { manager?.showHistory() } - scroll.show(panel) + scroll.show(panel.view) } is SessionControllerEvent.ViewChanged.ShowSession -> { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt index 0fb25f0b15f..f7aa3291725 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt @@ -7,8 +7,8 @@ import ai.kilocode.client.session.history.LocalHistoryItem import ai.kilocode.client.session.history.clicked import ai.kilocode.client.session.history.title import ai.kilocode.client.session.update.SessionController +import ai.kilocode.client.ui.CenterShrinkPanel import ai.kilocode.client.ui.UiStyle -import ai.kilocode.client.ui.md.MdView import ai.kilocode.rpc.dto.SessionDto import com.intellij.icons.AllIcons import com.intellij.openapi.Disposable @@ -17,10 +17,10 @@ import com.intellij.openapi.util.IconLoader import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBList import com.intellij.util.ui.Centerizer -import com.intellij.util.ui.JBDimension import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil import com.intellij.util.ui.components.BorderLayoutPanel +import com.intellij.xml.util.XmlStringUtil import java.awt.BorderLayout import java.awt.Component import java.awt.Cursor @@ -31,8 +31,6 @@ import java.awt.RenderingHints import java.awt.event.MouseAdapter import java.awt.event.MouseEvent import java.awt.event.MouseMotionAdapter -import javax.swing.Box -import javax.swing.BoxLayout import javax.swing.DefaultListModel import javax.swing.JButton import javax.swing.JList @@ -40,7 +38,10 @@ import javax.swing.ListCellRenderer import javax.swing.ListSelectionModel /** - * Centered empty-session panel. + * Empty-session panel. + * + * The content is a BorderLayout panel, wrapped in a + * [CenterShrinkPanel] (exposed as [view]) so callers need not know about centering. */ class EmptySessionPanel( parent: Disposable, @@ -51,19 +52,20 @@ class EmptySessionPanel( companion object { internal val LIMIT = UiStyle.Size.LIMIT - internal val MAX_WIDTH = UiStyle.Size.WIDTH + internal val DESCRIPTION_WIDTH = 250 } + val view: CenterShrinkPanel = CenterShrinkPanel(this) + private val model = DefaultListModel() private var hover = -1 private var style = SessionStyle.current() + private val recentTitle = JBLabel(KiloBundle.message("session.empty.recent")).apply { foreground = UIUtil.getContextHelpForeground() - border = JBUI.Borders.emptyLeft(UiStyle.Space.LG) } private val list = JBList(model).apply { - // Blend the recent-session list into the centered empty-state surface. isOpaque = false selectionMode = ListSelectionModel.SINGLE_SELECTION visibleRowCount = LIMIT @@ -90,26 +92,67 @@ class EmptySessionPanel( } }) } + private val historyButton = ShowHistoryButton().apply { - alignmentX = CENTER_ALIGNMENT addActionListener { history() } } - private val md = MdView.html().apply { - // MdView uses an HTML component; transparency keeps the centered panel seamless. - opaque = false + + private val welcomeLabel = JBLabel(welcomeHtml()).apply { foreground = UIUtil.getContextHelpForeground() - set(KiloBundle.message("session.empty.welcome")) + horizontalAlignment = JBLabel.CENTER + setAllowAutoWrapping(true) + } + + private val description = object : BorderLayoutPanel() { + override fun getPreferredSize(): Dimension { + val size = super.getPreferredSize() + return Dimension(JBUI.scale(DESCRIPTION_WIDTH), size.height) + } + + override fun getMaximumSize(): Dimension { + val size = super.getMaximumSize() + return Dimension(JBUI.scale(DESCRIPTION_WIDTH), size.height) + } + }.apply { + isOpaque = false + border = JBUI.Borders.empty(UiStyle.Space.LG, 0, UiStyle.Space.LG, 0) + add(welcomeLabel, BorderLayout.CENTER) } - private val content = createContent() init { Disposer.register(parent, this) - // The empty state floats on the tool-window background. isOpaque = false - border = UiStyle.Insets.empty() applyStyle(SessionStyle.current()) setSessions(recents) - add(Centerizer(content, Centerizer.TYPE.BOTH), BorderLayout.CENTER) + + val gap = JBUI.scale(UiStyle.Gap.turn()) + layout = BorderLayout(0, gap) + + val logo = JBLabel( + IconLoader.getIcon("/icons/kilo-content.svg", EmptySessionPanel::class.java), + ).apply { + horizontalAlignment = JBLabel.CENTER + } + val header = BorderLayoutPanel(0, gap).apply { + isOpaque = false + add(logo, BorderLayout.NORTH) + add(CenterShrinkPanel(description), BorderLayout.CENTER) + } + + val recent = BorderLayoutPanel().apply { + isOpaque = false + add(recentTitle, BorderLayout.NORTH) + add(list, BorderLayout.CENTER) + } + + val south = BorderLayoutPanel().apply { + isOpaque = false + add(Centerizer(historyButton, Centerizer.TYPE.HORIZONTAL), BorderLayout.CENTER) + } + + add(header, BorderLayout.NORTH) + add(recent, BorderLayout.CENTER) + add(south, BorderLayout.SOUTH) } private fun setSessions(sessions: List) { @@ -119,44 +162,6 @@ class EmptySessionPanel( repaint() } - private fun createContent(): BorderLayoutPanel { - val logo = JBLabel( - IconLoader.getIcon("/icons/kilo-content.svg", EmptySessionPanel::class.java), - ).apply { - alignmentX = CENTER_ALIGNMENT - } - val intro = BorderLayoutPanel().apply { - alignmentX = CENTER_ALIGNMENT - add(md.component, BorderLayout.CENTER) - border = JBUI.Borders.empty(0, UiStyle.Space.PAD, 0, UiStyle.Space.PAD) - } - val recent = BorderLayoutPanel().apply { - alignmentX = CENTER_ALIGNMENT - add(recentTitle, BorderLayout.NORTH) - add(list, BorderLayout.CENTER) - add(BorderLayoutPanel().apply { - border = JBUI.Borders.emptyTop(UiStyle.Space.LG) - add(historyButton, BorderLayout.CENTER) - }, BorderLayout.SOUTH) - } - val stack = BorderLayoutPanel().apply { - layout = BoxLayout(this, BoxLayout.Y_AXIS) - add(logo) - add(Box.createVerticalStrut(JBUI.scale(UiStyle.Space.LOGO))) - add(intro) - add(Box.createVerticalStrut(JBUI.scale(UiStyle.Space.RECENT))) - add(recent) - } - return object : BorderLayoutPanel() { - override fun getPreferredSize(): Dimension { - val size = super.getPreferredSize() - return JBDimension(JBUI.scale(MAX_WIDTH), size.height) - } - }.apply { - add(stack, BorderLayout.NORTH) - } - } - internal fun recentCount() = model.size() internal fun selectRecent(index: Int) { @@ -184,9 +189,15 @@ class EmptySessionPanel( internal fun recentVisible() = true - internal fun explanationMarkdown() = md.markdown() + internal fun explanationText() = KiloBundle.message("session.empty.welcome") - internal fun contentPreferredSize() = content.preferredSize + internal fun welcomeLabelAlignment() = welcomeLabel.horizontalAlignment + + internal fun descriptionPreferredSize() = description.preferredSize + + internal fun descriptionMaximumSize() = description.maximumSize + + internal fun historyButtonPreferredWidth() = historyButton.preferredSize.width internal fun initialized() = true @@ -194,7 +205,8 @@ class EmptySessionPanel( internal fun activeView() = getComponent(0) - internal fun text(session: SessionDto, now: Long = System.currentTimeMillis()) = HistoryTime.relative(LocalHistoryItem(session), now) + internal fun text(session: SessionDto, now: Long = System.currentTimeMillis()) = + HistoryTime.relative(LocalHistoryItem(session), now) internal fun rendererComponent( session: SessionDto, @@ -221,6 +233,7 @@ class EmptySessionPanel( private val time = JBLabel() init { + layout = BorderLayout(JBUI.scale(UiStyle.Space.LG), 0) border = JBUI.Borders.empty(UiStyle.Space.LG, UiStyle.Space.LG, UiStyle.Space.LG, UiStyle.Space.LG) add(title, BorderLayout.CENTER) add(time, BorderLayout.EAST) @@ -253,7 +266,6 @@ class EmptySessionPanel( isContentAreaFilled = false isBorderPainted = false isOpaque = false - border = JBUI.Borders.empty(UiStyle.Space.SM, UiStyle.Space.LG) cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) addMouseListener(object : MouseAdapter() { override fun mouseEntered(e: MouseEvent) { @@ -294,9 +306,13 @@ class EmptySessionPanel( override fun applyStyle(style: SessionStyle) { this.style = style - md.font = style.uiFont + welcomeLabel.font = style.uiFont recentTitle.font = style.smallUiFont revalidate() repaint() } + + private fun welcomeHtml() = XmlStringUtil.wrapInHtml( + "
${XmlStringUtil.escapeString(KiloBundle.message("session.empty.welcome"))}
" + ) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/CenterShrinkPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/CenterShrinkPanel.kt new file mode 100644 index 00000000000..57e7d95e5f4 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/CenterShrinkPanel.kt @@ -0,0 +1,38 @@ +package ai.kilocode.client.ui + +import java.awt.Component +import java.awt.Dimension +import javax.swing.JPanel + +/** + * Centers its single child and shrinks it to available space when needed. + * If available space is larger than the child's maximum size, the child is not expanded. + */ +class CenterShrinkPanel(child: Component) : JPanel(null) { + init { + isOpaque = false + add(child) + } + + override fun doLayout() { + if (componentCount == 0) return + val child = getComponent(0) + val insets = getInsets() + val availW = width - insets.left - insets.right + val availH = height - insets.top - insets.bottom + val pref = child.preferredSize + val max = child.maximumSize + val w = minOf(pref.width, max.width, availW) + val h = minOf(pref.height, max.height, availH) + val x = insets.left + (availW - w) / 2 + val y = insets.top + (availH - h) / 2 + child.setBounds(x, y, w, h) + } + + override fun getPreferredSize(): Dimension { + if (componentCount == 0) return super.getPreferredSize() + val pref = getComponent(0).preferredSize + val insets = getInsets() + return Dimension(pref.width + insets.left + insets.right, pref.height + insets.top + insets.bottom) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt index 948352b610a..1955144ea5a 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt @@ -84,11 +84,24 @@ class EmptySessionPanelTest : BasePlatformTestCase() { assertTrue(panel.preferredSize.height > 0) } - fun `test content has fixed preferred width`() { + fun `test description width is capped at DESCRIPTION_WIDTH`() { val panel = panel() - assertEquals(com.intellij.util.ui.JBUI.scale(EmptySessionPanel.MAX_WIDTH), panel.contentPreferredSize().width) - assertTrue(panel.contentPreferredSize().height > 0) + assertEquals(com.intellij.util.ui.JBUI.scale(EmptySessionPanel.DESCRIPTION_WIDTH), panel.descriptionPreferredSize().width) + assertEquals(com.intellij.util.ui.JBUI.scale(EmptySessionPanel.DESCRIPTION_WIDTH), panel.descriptionMaximumSize().width) + } + + fun `test description label is centered`() { + val panel = panel() + + assertEquals(javax.swing.SwingConstants.CENTER, panel.welcomeLabelAlignment()) + } + + fun `test show history button has its own preferred width`() { + val panel = panel() + val btn = panel.historyButtonPreferredWidth() + + assertTrue(btn > 0) } fun `test recent sessions are capped at five`() { @@ -98,12 +111,12 @@ class EmptySessionPanelTest : BasePlatformTestCase() { assertEquals(5, panel.recentCount()) } - fun `test explanation uses markdown view`() { + fun `test explanation uses welcome message`() { val panel = panel() assertEquals( "Kilo Code is an AI coding assistant. Ask it to build features, fix bugs, or explain your codebase.", - panel.explanationMarkdown(), + panel.explanationText(), ) }