fix(jetbrains): align session question views

This commit is contained in:
kirillk
2026-05-21 10:26:23 -04:00
parent 835781830e
commit 20bae4f3d9
12 changed files with 322 additions and 23 deletions
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Improve question-based session views so UI text uses editor-sized interface fonts, actions align consistently, and permission prompts show a header icon.
@@ -5,14 +5,17 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.ui.RoundedContentPanel
import ai.kilocode.client.ui.UiStyle
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBTextArea
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.util.ui.JBUI
import java.awt.BorderLayout
import java.awt.Color
import java.awt.Component
import java.awt.Dimension
import javax.swing.Box
import javax.swing.BoxLayout
import javax.swing.Icon
import javax.swing.JComponent
import javax.swing.JPanel
@@ -27,9 +30,9 @@ import javax.swing.JPanel
* outer card shell so they share the same background, padding, and text
* styling without duplicating the setup.
*
* The column always contains (in order): optional top, [headerText],
* The column always contains (in order): optional top, header row with [headerText],
* [descriptionText], optional body, optional footer. Call [setTopPanel],
* [setBody], or [setFooter] to replace those slots at any time.
* [setHeaderIcon], [setBody], or [setFooter] to replace those slots at any time.
*/
class BaseSessionQuestionPanel : RoundedContentPanel(
UiStyle.Gap.lg(),
@@ -38,7 +41,7 @@ class BaseSessionQuestionPanel : RoundedContentPanel(
private var style = SessionEditorStyle.current()
// All JBTextArea instances that need editor-font updates, paired with bold flag
// All JBTextArea instances that need style updates, paired with bold flag
private val tracked = mutableListOf<Pair<JBTextArea, Boolean>>()
// ---- header text ----
@@ -47,6 +50,23 @@ class BaseSessionQuestionPanel : RoundedContentPanel(
// ---- description text ----
val descriptionText: JBTextArea = makeText("", UiStyle.Colors.weak(), bold = false)
private val icon = JBLabel().apply {
border = JBUI.Borders.emptyRight(UiStyle.Gap.sm())
isVisible = false
}
private val header = object : JPanel(BorderLayout(UiStyle.Gap.sm(), 0)) {
override fun getMaximumSize(): Dimension {
val size = preferredSize
return Dimension(Int.MAX_VALUE, size.height)
}
}.apply {
isOpaque = false
alignmentX = Component.LEFT_ALIGNMENT
add(icon, BorderLayout.WEST)
add(headerText, BorderLayout.CENTER)
}
// ---- slot fields ----
private var top: JComponent? = null
private var body: JComponent? = null
@@ -77,6 +97,19 @@ class BaseSessionQuestionPanel : RoundedContentPanel(
rebuildCol()
}
/**
* Optional icon rendered at the left edge of the header row.
* Pass `null` to remove the icon while keeping header text alignment stable.
*/
@RequiresEdt
fun setHeaderIcon(icon: Icon?, tooltip: String? = null) {
this.icon.icon = icon
this.icon.toolTipText = tooltip
this.icon.isVisible = icon != null
this.icon.revalidate()
this.icon.repaint()
}
/**
* Replace the body slot that comes after the header/description.
* Pass `null` to remove the current body.
@@ -116,7 +149,7 @@ class BaseSessionQuestionPanel : RoundedContentPanel(
private fun rebuildCol() {
col.removeAll()
top?.let { col.add(it) }
col.add(headerText)
col.add(header)
col.add(descriptionText)
body?.let {
col.add(gap())
@@ -182,7 +215,7 @@ class BaseSessionQuestionPanel : RoundedContentPanel(
}
private fun applyFont(area: JBTextArea, bold: Boolean) {
val font = if (bold) style.boldEditorFont else style.transcriptFont
val font = if (bold) style.boldUiFont else style.uiFont
if (area.font != font) area.font = font
}
}
@@ -7,10 +7,13 @@ import ai.kilocode.client.session.ui.shared.applyButton
import ai.kilocode.client.session.ui.shared.dismissButton
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
import ai.kilocode.client.ui.UiStyle
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.util.ui.components.BorderLayoutPanel
import java.awt.BorderLayout
import java.awt.Component
import javax.swing.Box
import javax.swing.BoxLayout
import javax.swing.JPanel
/**
@@ -43,9 +46,15 @@ class LoginRequiredView(
val footer = JPanel(BorderLayout()).apply {
isOpaque = false
alignmentX = Component.LEFT_ALIGNMENT
add(dismissButton, BorderLayout.WEST)
add(openProfileButton, BorderLayout.EAST)
}
val actions = JPanel().apply {
isOpaque = false
layout = BoxLayout(this, BoxLayout.X_AXIS)
add(dismissButton)
add(Box.createHorizontalStrut(UiStyle.Gap.sm()))
add(openProfileButton)
}
footer.add(actions, BorderLayout.EAST)
card.setFooter(footer)
@@ -14,6 +14,7 @@ import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.md.MdView
import ai.kilocode.rpc.dto.PermissionReplyDto
import com.intellij.icons.AllIcons
import com.intellij.ui.components.JBScrollPane
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.components.BorderLayoutPanel
@@ -75,6 +76,7 @@ class PermissionView(
actions.add(run)
footer.add(actions, BorderLayout.EAST)
card.setHeaderIcon(AllIcons.General.Warning, KiloBundle.message("session.permission.title"))
card.setBody(body)
card.setFooter(footer)
addToCenter(card)
@@ -239,6 +241,8 @@ class PermissionView(
// Test helpers
internal fun runButtonForTest() = run
internal fun denyButtonForTest() = deny
internal fun firstCmdViewForTest() = cmdViews.firstOrNull()
internal fun headerFontForTest() = card.headerText.font
}
/**
@@ -108,7 +108,9 @@ class QuestionResultView(tool: Tool) : PartView() {
override fun applyStyle(style: SessionEditorStyle) {
this.style = style
val label = setFont(title, style.boldEditorFont) || setFont(sub, style.smallEditorFont)
val t = setFont(title, style.boldUiFont)
val s = setFont(sub, style.smallUiFont)
val label = t || s
val body = texts.fold(false) { acc, item -> setFont(item.first, item.second) || acc }
if (!label && !body) return
refresh()
@@ -137,6 +139,9 @@ class QuestionResultView(tool: Tool) : PartView() {
fun bodyFonts(): List<Font> = texts.map { it.first.font }
fun titleFont(): Font = title.font
fun subFont(): Font = sub.font
override fun dumpLabel(): String = "QuestionResultView#$contentId(${labelText()})"
companion object {
@@ -268,7 +273,7 @@ class QuestionResultView(tool: Tool) : PartView() {
}
private fun setFont(area: JBTextArea, bold: Boolean): Boolean {
val font = if (bold) style.boldEditorFont else style.transcriptFont
val font = if (bold) style.boldUiFont else style.uiFont
if (area.font == font) return false
area.font = font
return true
@@ -98,7 +98,6 @@ class QuestionView(
nav.add(fwd)
topPanel.add(summary, BorderLayout.WEST)
topPanel.add(nav, BorderLayout.EAST)
footer.add(dismiss, BorderLayout.WEST)
footer.add(right, BorderLayout.EAST)
card.setTopPanel(topPanel)
@@ -176,9 +175,11 @@ class QuestionView(
private fun syncFooter(q: Question) {
right.removeAll()
right.add(dismiss)
if (review(q)) {
val back = dismissButton(KiloBundle.message("session.question.back")) { goBack() }
val submit = applyButton(KiloBundle.message("session.question.submit")) { doReply() }
right.add(Box.createHorizontalStrut(UiStyle.Gap.sm()))
right.add(back)
right.add(Box.createHorizontalStrut(UiStyle.Gap.sm()))
right.add(submit)
@@ -200,6 +201,7 @@ class QuestionView(
}
}
}
right.add(Box.createHorizontalStrut(UiStyle.Gap.sm()))
right.add(button)
}
@@ -207,8 +209,10 @@ class QuestionView(
val ready = selections.getOrNull(idx)?.isNotEmpty() == true
back.isEnabled = idx > 0
fwd.isEnabled = idx < q.items.size && ready
val backLabel = KiloBundle.message("session.question.back")
val dismissLabel = KiloBundle.message("session.question.dismiss")
for (node in right.components) {
if (node is SessionQuestionButton && node.text != KiloBundle.message("session.question.back")) {
if (node is SessionQuestionButton && node.text != backLabel && node.text != dismissLabel) {
node.isEnabled = review(q) || ready
}
}
@@ -436,7 +440,7 @@ class QuestionView(
}
private fun setFont(area: JBTextArea, bold: Boolean): Boolean {
val font = if (bold) style.boldEditorFont else style.transcriptFont
val font = if (bold) style.boldUiFont else style.uiFont
if (area.font == font) return false
area.font = font
return true
@@ -50,4 +50,22 @@ class SessionEditorStyleTest : BasePlatformTestCase() {
assertTrue(style.smallEditorFont.size < style.editorSize)
assertEquals(style.editorSize, style.uiFont.size)
}
fun `test ui fonts use platform label family not editor family`() {
val style = SessionEditorStyle.create(family = "Courier New", size = 22)
// uiFont / boldUiFont / smallUiFont must NOT use the editor font family
assertFalse("uiFont should not use editor font family", style.uiFont.name == "Courier New")
assertFalse("boldUiFont should not use editor font family", style.boldUiFont.name == "Courier New")
assertFalse("smallUiFont should not use editor font family", style.smallUiFont.name == "Courier New")
}
fun `test ui fonts inherit editor size`() {
val style = SessionEditorStyle.create(family = "Courier New", size = 22)
assertEquals("uiFont size should match editor size", 22, style.uiFont.size)
assertEquals("boldUiFont size should match editor size", 22, style.boldUiFont.size)
assertTrue("boldUiFont should be bold", style.boldUiFont.isBold)
assertTrue("smallUiFont should be smaller than editor size", style.smallUiFont.size < style.editorSize)
}
}
@@ -1,8 +1,12 @@
package ai.kilocode.client.session.ui.shared
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import com.intellij.icons.AllIcons
import com.intellij.openapi.application.ApplicationManager
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBTextArea
import java.awt.BorderLayout
import java.awt.Container
import javax.swing.JComponent
import javax.swing.JLabel
@@ -40,7 +44,7 @@ class BaseSessionQuestionPanelTest : BasePlatformTestCase() {
val col = findCol(panel)!!
val comps = col.components.toList()
val topIdx = comps.indexOf(top)
val headerIdx = comps.indexOf(panel.headerText)
val headerIdx = comps.indexOf(panel.headerText.parent)
assertTrue("top should appear before headerText", topIdx < headerIdx)
}
}
@@ -169,7 +173,7 @@ class BaseSessionQuestionPanelTest : BasePlatformTestCase() {
val col = findCol(panel)!!
val comps = col.components.toList()
val topIdx = comps.indexOf(top)
val headerIdx = comps.indexOf(panel.headerText)
val headerIdx = comps.indexOf(panel.headerText.parent)
val descIdx = comps.indexOf(panel.descriptionText)
val bodyIdx = comps.indexOf(body)
val footerIdx = comps.indexOf(footer)
@@ -195,7 +199,38 @@ class BaseSessionQuestionPanelTest : BasePlatformTestCase() {
edt {
val panel = BaseSessionQuestionPanel()
val col = findCol(panel)!!
assertEquals("headerText + descriptionText only", 2, col.componentCount)
assertEquals("header row + descriptionText only", 2, col.componentCount)
}
}
// ------ header left icon ------
fun `test setHeaderIcon adds icon to the left side of header row`() {
edt {
val panel = BaseSessionQuestionPanel()
panel.setHeaderIcon(AllIcons.General.Warning, "warning")
val header = panel.headerText.parent as JPanel
val layout = header.layout as BorderLayout
val labels = findAll<JBLabel>(header).filter { it.icon != null }
assertEquals("Expected one header icon", 1, labels.size)
assertSame(AllIcons.General.Warning, labels[0].icon)
assertEquals("warning", labels[0].toolTipText)
assertEquals(BorderLayout.WEST, layout.getConstraints(labels[0]))
assertEquals(BorderLayout.CENTER, layout.getConstraints(panel.headerText))
}
}
fun `test setHeaderIcon null hides header icon without removing header row`() {
edt {
val panel = BaseSessionQuestionPanel()
panel.setHeaderIcon(AllIcons.General.Warning)
panel.setHeaderIcon(null)
val header = panel.headerText.parent as Container
val labels = findAll<JBLabel>(header).filter { it.icon != null && it.isVisible }
assertTrue("Header icon should be hidden after setHeaderIcon(null)", labels.isEmpty())
assertSame(header, panel.headerText.parent)
}
}
@@ -243,6 +278,36 @@ class BaseSessionQuestionPanelTest : BasePlatformTestCase() {
}
}
// ------ applyStyle: UI fonts ------
fun `test applyStyle applies boldUiFont to header and uiFont to description`() {
edt {
val panel = BaseSessionQuestionPanel()
val style = SessionEditorStyle.create(family = "Courier New", size = 20)
panel.applyStyle(style)
assertEquals("headerText should use boldUiFont", style.boldUiFont, panel.headerText.font)
assertEquals("descriptionText should use uiFont", style.uiFont, panel.descriptionText.font)
}
}
fun `test applyStyle does not apply editor font family to header or description`() {
edt {
val panel = BaseSessionQuestionPanel()
val style = SessionEditorStyle.create(family = "Courier New", size = 20)
panel.applyStyle(style)
assertFalse(
"headerText should not use editor font family",
panel.headerText.font.name == "Courier New",
)
assertFalse(
"descriptionText should not use editor font family",
panel.descriptionText.font.name == "Courier New",
)
}
}
// ------ helpers ------
private fun <T> edt(block: () -> T): T {
@@ -270,4 +335,15 @@ class BaseSessionQuestionPanelTest : BasePlatformTestCase() {
}
return null
}
private inline fun <reified T> findAll(root: Container): List<T> = findAllCls(root, T::class.java)
private fun <T> findAllCls(root: Container, cls: Class<T>): List<T> {
val result = mutableListOf<T>()
if (cls.isInstance(root)) result.add(cls.cast(root))
for (child in root.components) {
if (child is Container) result.addAll(findAllCls(child, cls))
}
return result
}
}
@@ -1,6 +1,7 @@
package ai.kilocode.client.session.views
import ai.kilocode.client.session.ui.shared.SessionQuestionButton
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionUiStyle
import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI
import com.intellij.openapi.application.ApplicationManager
@@ -105,6 +106,18 @@ class LoginRequiredViewTest : BasePlatformTestCase() {
}
}
fun `test login action buttons share right-aligned footer group`() {
edt {
val view = LoginRequiredView(openProfile = {}, dismiss = {})
view.show("Sign in required.")
val dismiss = view.dismissButton
val open = view.openProfileButton
assertSame("Dismiss and open profile should be in the same right-aligned group", dismiss.parent, open.parent)
assertTrue("Dismiss should appear before open profile", dismiss.parent.components.indexOf(dismiss) < open.parent.components.indexOf(open))
}
}
// ------ callbacks ------
fun `test open profile button click invokes openProfile callback`() {
@@ -161,6 +174,42 @@ class LoginRequiredViewTest : BasePlatformTestCase() {
}
}
// ------ fonts: UI family, editor size ------
fun `test header uses boldUiFont not editor font family`() {
edt {
val view = LoginRequiredView(openProfile = {}, dismiss = {})
view.show("Sign in required.")
val style = SessionEditorStyle.create(family = "Courier New", size = 20)
view.applyStyle(style)
val title = findAll<JBTextArea>(view).firstOrNull { it.font.isBold }
assertNotNull("Bold title text area should be present", title)
assertFalse(
"Title font should not use editor font family",
title!!.font.name == "Courier New",
)
assertEquals("Title font size should match editor size", 20, title.font.size)
}
}
fun `test description uses uiFont not editor font family`() {
edt {
val view = LoginRequiredView(openProfile = {}, dismiss = {})
view.show("Sign in required.")
val style = SessionEditorStyle.create(family = "Courier New", size = 20)
view.applyStyle(style)
val desc = findAll<JBTextArea>(view).firstOrNull { it.text == "Sign in required." }
assertNotNull("Description text area should be present", desc)
assertFalse(
"Description font should not use editor font family",
desc!!.font.name == "Courier New",
)
assertEquals("Description font size should match editor size", 20, desc.font.size)
}
}
// ------ helpers ------
private fun <T> edt(block: () -> T): T {
@@ -5,11 +5,14 @@ import ai.kilocode.client.session.model.PermissionFileDiff
import ai.kilocode.client.session.model.PermissionMeta
import ai.kilocode.client.session.model.PermissionRequestState
import ai.kilocode.client.session.ui.shared.BaseSessionQuestionPanel
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.rpc.dto.PermissionReplyDto
import com.intellij.icons.AllIcons
import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.ui.components.JBHtmlPane
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBScrollPane
import java.awt.Container
import javax.swing.AbstractButton
@@ -262,6 +265,16 @@ class PermissionViewTest : BasePlatformTestCase() {
assertTrue("Expected a BaseSessionQuestionPanel after show", panels.isNotEmpty())
}
fun `test permission icon is rendered in header`() {
view.show(permission())
val labels = findAll<JBLabel>(view)
assertTrue(
"Expected permission warning icon in header",
labels.any { it.icon == AllIcons.General.Warning },
)
}
// ------ new: shared button types ------
fun `test run button is SessionQuestionButton with primary true`() {
@@ -355,6 +368,47 @@ class PermissionViewTest : BasePlatformTestCase() {
assertEquals(SessionUiStyle.View.headerHover(), scroll.viewport.background)
}
// ------ fonts: header UI family, command code block editor family ------
fun `test permission header uses boldUiFont not editor font family`() {
view.show(
Permission(
id = "perm_font",
sessionId = "ses",
name = "bash",
patterns = emptyList(),
always = emptyList(),
meta = PermissionMeta(command = "ls"),
)
)
val style = SessionEditorStyle.create(family = "Courier New", size = 18)
view.applyStyle(style)
val header = view.headerFontForTest()
assertFalse("Permission header should not use editor font family", header.name == "Courier New")
assertTrue("Permission header should be bold", header.isBold)
assertEquals("Permission header size should match editor size", 18, header.size)
}
fun `test command code block retains editor font family`() {
view.show(
Permission(
id = "perm_codefont",
sessionId = "ses",
name = "bash",
patterns = emptyList(),
always = emptyList(),
meta = PermissionMeta(command = "git log"),
)
)
val style = SessionEditorStyle.create(family = "Courier New", size = 18)
view.applyStyle(style)
val md = view.firstCmdViewForTest()
assertNotNull("Should have at least one command MdView", md)
assertEquals("Code block codeFont should use editor family", "Courier New", md!!.codeFont)
}
private fun permission() = Permission(
id = "perm1",
sessionId = "ses_test",
@@ -166,7 +166,7 @@ class QuestionResultViewTest : BasePlatformTestCase() {
// ------ applyStyle ------
fun `test applyStyle updates fonts`() {
fun `test applyStyle updates body fonts to UI font family`() {
val tool = completedTool(
input = mapOf("questions" to """[{"question":"Q1"}]"""),
metadata = mapOf("answers" to """[["A1"]]"""),
@@ -177,8 +177,25 @@ class QuestionResultViewTest : BasePlatformTestCase() {
view.applyStyle(style)
view.toggle()
assertTrue(view.bodyFonts().contains(style.transcriptFont))
assertTrue(view.bodyFonts().contains(style.boldEditorFont))
assertTrue(view.bodyFonts().contains(style.uiFont))
assertTrue(view.bodyFonts().contains(style.boldUiFont))
assertFalse("Body should not use editor transcript font", view.bodyFonts().any { it.name == "Courier New" })
}
fun `test applyStyle updates header label fonts to UI font family`() {
val tool = completedTool(
input = mapOf("questions" to """[{"question":"Q1"}]"""),
metadata = mapOf("answers" to """[["A1"]]"""),
)
val view = QuestionResultView(tool)
val style = SessionEditorStyle.create(family = "Courier New", size = 22)
view.applyStyle(style)
assertEquals("Title should use boldUiFont", style.boldUiFont, view.titleFont())
assertEquals("Subtitle should use smallUiFont", style.smallUiFont, view.subFont())
assertFalse("Title should not use editor font family", view.titleFont().name == "Courier New")
assertFalse("Subtitle should not use editor font family", view.subFont().name == "Courier New")
}
// ------ update ------
@@ -87,6 +87,31 @@ class QuestionViewTest : BasePlatformTestCase() {
assertTrue(replies.isEmpty())
}
fun `test question action buttons share right-aligned footer group`() {
view.show(singleSelectQuestion("req_actions"))
val dismiss = button(view, "Dismiss")
val submit = button(view, "Submit")
assertSame("Dismiss and Submit should be in the same right-aligned group", dismiss.parent, submit.parent)
assertTrue("Dismiss should appear before Submit", dismiss.parent.components.indexOf(dismiss) < submit.parent.components.indexOf(submit))
}
fun `test review action buttons share right-aligned footer group`() {
view.show(twoItemQuestion("req_review_actions"))
option<JBRadioButton>(view, "Minimal").doClick()
button(view, "Next").doClick()
option<JBRadioButton>(view, "Unit").doClick()
button(view, "Review").doClick()
val dismiss = button(view, "Dismiss")
val back = button(view, "Back")
val submit = button(view, "Submit")
assertSame("Dismiss and Back should be in the same right-aligned group", dismiss.parent, back.parent)
assertSame("Back and Submit should be in the same right-aligned group", back.parent, submit.parent)
assertTrue("Dismiss should appear before Back", dismiss.parent.components.indexOf(dismiss) < back.parent.components.indexOf(back))
assertTrue("Back should appear before Submit", back.parent.components.indexOf(back) < submit.parent.components.indexOf(submit))
}
// ------ radio options ------
fun `test single question renders radio options`() {
@@ -159,19 +184,19 @@ class QuestionViewTest : BasePlatformTestCase() {
assertEquals("description should align in the text renderer", label.parent, desc.parent)
val style = SessionEditorStyle.current()
assertEquals("option label should use bold editor font", style.boldEditorFont, label.font)
assertEquals("description should use transcript font", style.transcriptFont, desc.font)
assertEquals("option label should use boldUiFont", style.boldUiFont, label.font)
assertEquals("description should use uiFont", style.uiFont, desc.font)
}
fun `test question title and hint use editor fonts`() {
fun `test question title and hint use UI-family editor-sized fonts`() {
view.show(singleSelectQuestion("q_fonts"))
val style = SessionEditorStyle.current()
val title = text(view, "Choose approach")
val hint = text(view, "Select one answer")
assertEquals(style.boldEditorFont, title.font)
assertEquals(style.transcriptFont, hint.font)
assertEquals(style.boldUiFont, title.font)
assertEquals(style.uiFont, hint.font)
}
// ------ multi-question navigation ------