From 4ffcfabe1e7ecd935c5a86680f8ddd109a782855 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 18 May 2026 11:29:51 -0400 Subject: [PATCH] feat(jetbrains): show questions one at a time with radio/checkbox rows Refactor QuestionView from a flat all-at-once layout to a carousel that shows one question per screen, matching the VS Code question dock pattern: - Shows 'X of Y questions' summary in the card header - Back/forward HoverIcon nav buttons in the header; forward disabled until the current question has an answer - Single-select items render as JBRadioButton rows (bold label + description) - Multi-select items render as JBCheckBox rows - Footer: Dismiss on the left; Next (middle questions) or Submit (last) - Selections survive back/forward navigation - Tests updated: radio/checkbox discovery, navigation, back/selection preservation, and disabled-next guard --- .changeset/jetbrains-question-carousel.md | 5 + .../client/session/views/QuestionView.kt | 344 ++++++++++++++---- .../resources/messages/KiloBundle.properties | 5 + .../client/session/views/QuestionViewTest.kt | 257 ++++++++++--- 4 files changed, 490 insertions(+), 121 deletions(-) create mode 100644 .changeset/jetbrains-question-carousel.md diff --git a/.changeset/jetbrains-question-carousel.md b/.changeset/jetbrains-question-carousel.md new file mode 100644 index 00000000000..0e9b5f72bc2 --- /dev/null +++ b/.changeset/jetbrains-question-carousel.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Show JetBrains question prompts one question at a time with radio-style option rows. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/QuestionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/QuestionView.kt index a5dd2e1c2bf..bd122b3e7ce 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/QuestionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/QuestionView.kt @@ -3,26 +3,36 @@ package ai.kilocode.client.session.views import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.model.Question import ai.kilocode.client.session.model.QuestionItem +import ai.kilocode.client.session.model.QuestionOption import ai.kilocode.client.session.ui.SessionView import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.ui.HoverIcon import ai.kilocode.rpc.dto.QuestionReplyDto import com.intellij.icons.AllIcons -import com.intellij.ui.dsl.builder.RightGap -import com.intellij.ui.dsl.builder.RowLayout -import com.intellij.ui.dsl.builder.TopGap -import com.intellij.ui.dsl.builder.panel +import com.intellij.ui.components.JBCheckBox +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBRadioButton +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil import com.intellij.util.ui.components.BorderLayoutPanel import java.awt.BorderLayout +import java.awt.Font +import javax.swing.AbstractButton +import javax.swing.BoxLayout +import javax.swing.ButtonGroup +import javax.swing.JButton +import javax.swing.JPanel /** * Transcript-style question view — rendered inside [ai.kilocode.client.session.ui.SessionMessageListPanel] * at the end of the transcript when the session is in * [ai.kilocode.client.session.model.SessionState.AwaitingQuestion]. * - * Unlike the old docked [ai.kilocode.client.session.ui.QuestionPanel], this view lives inside - * the scrollable transcript so the user can scroll through prior messages while a question is active. + * Shows one [QuestionItem] at a time as a carousel with back/forward navigation. + * Single-select items render as radio-button rows; multi-select as checkbox rows. + * Each option shows a bold label and a regular description. */ class QuestionView( private val reply: (String, QuestionReplyDto) -> Unit, @@ -31,6 +41,9 @@ class QuestionView( override val sessionViewKind = SessionView.Kind.Default private var requestId: String? = null + private var question: Question? = null + private var idx = 0 + private var selections = emptyList>() private var style = SessionEditorStyle.current() init { @@ -38,16 +51,43 @@ class QuestionView( isVisible = false } - /** Populate the view for all items in [question] and make it visible. */ - fun show(question: Question) { - if (question.items.isEmpty()) { + /** Populate the view for [q] and make it visible, starting at the first question. */ + fun show(q: Question) { + if (q.items.isEmpty()) { hideView() return } - requestId = question.id + requestId = q.id + question = q + idx = 0 + selections = List(q.items.size) { mutableSetOf() } + render() + isVisible = true + refresh() + } - // Per-item selected answers: index → mutable set of selected labels - val selections = Array(question.items.size) { mutableSetOf() } + /** Hide this view and clear all active request state. */ + fun hideView() { + requestId = null + question = null + idx = 0 + selections = emptyList() + removeAll() + isVisible = false + refresh() + } + + override fun applyStyle(s: SessionEditorStyle) { + style = s + } + + // ------ private rendering ------ + + private fun render() { + val q = question ?: return + val item = q.items[idx] + val total = q.items.size + val set = selections[idx] removeAll() @@ -55,72 +95,240 @@ class QuestionView( card.isOpaque = true card.background = SessionUiStyle.View.surface() card.border = SessionUiStyle.View.card() - - card.add(panel { - for ((idx, item) in question.items.withIndex()) { - if (idx > 0) row { }.topGap(TopGap.SMALL) - row { - icon(AllIcons.General.QuestionDialog).gap(RightGap.SMALL) - label(item.header).bold() - } - row { - label(item.question) - } - row { - for (opt in item.options) { - button(opt.label) { - toggleOption(selections, idx, item, opt.label) - } - .gap(RightGap.SMALL) - .applyToComponent { toolTipText = opt.description } - } - }.layout(RowLayout.INDEPENDENT) - } - - row { - button(KiloBundle.message("session.question.submit")) { - doReply(selections.map { it.toList() }) - }.gap(RightGap.SMALL) - button(KiloBundle.message("session.question.dismiss")) { doReject() } - }.layout(RowLayout.INDEPENDENT).topGap(TopGap.SMALL) - }.also { it.isOpaque = false }, BorderLayout.CENTER) - + card.add(buildContent(item, total, set), BorderLayout.CENTER) add(card, BorderLayout.CENTER) - - isVisible = true - refresh() } - /** Hide this view and clear the active request id. */ - fun hideView() { - requestId = null - removeAll() - isVisible = false - refresh() + private fun buildContent(item: QuestionItem, total: Int, set: MutableSet): JPanel { + val root = JPanel() + root.isOpaque = false + root.layout = BoxLayout(root, BoxLayout.Y_AXIS) + root.border = JBUI.Borders.empty(8, 12, 8, 12) + + root.add(header(total)) + root.add(body(item, set)) + root.add(footer(item, set)) + + return root } - override fun applyStyle(style: SessionEditorStyle) { - this.style = style - } + // ── Header: icon + summary + nav buttons ────────────────────────────────── - private fun toggleOption( - selections: Array>, - idx: Int, - item: QuestionItem, - label: String, - ) { - val set = selections[idx] - if (item.multiple) { - if (!set.remove(label)) set.add(label) - } else { - set.clear() - set.add(label) + private fun header(total: Int): JPanel { + val row = JPanel(BorderLayout()) + row.isOpaque = false + row.border = JBUI.Borders.emptyBottom(6) + + val left = JPanel() + left.isOpaque = false + left.layout = BoxLayout(left, BoxLayout.X_AXIS) + + val icon = JBLabel(AllIcons.General.QuestionDialog) + icon.border = JBUI.Borders.emptyRight(6) + left.add(icon) + + val summary = JBLabel(KiloBundle.message("session.question.summary", idx + 1, total)) + summary.foreground = UIUtil.getContextHelpForeground() + left.add(summary) + + row.add(left, BorderLayout.WEST) + + if (total > 1) { + row.add(navButtons(), BorderLayout.EAST) } + + return row } - private fun doReply(answers: List>) { + private fun navButtons(): JPanel { + val nav = JPanel() + nav.isOpaque = false + nav.layout = BoxLayout(nav, BoxLayout.X_AXIS) + + val back = HoverIcon().apply { + icon = AllIcons.Actions.Back + toolTipText = KiloBundle.message("session.question.back") + isEnabled = idx > 0 + addActionListener { goBack() } + } + + val fwd = HoverIcon().apply { + icon = AllIcons.Actions.Forward + toolTipText = KiloBundle.message("session.question.next") + val q = question + isEnabled = q != null && idx < q.items.size - 1 && selections[idx].isNotEmpty() + addActionListener { goForward() } + } + + nav.add(back) + nav.add(fwd) + return nav + } + + // ── Body: question text + hint + options ────────────────────────────────── + + private fun body(item: QuestionItem, set: MutableSet): JPanel { + val panel = JPanel() + panel.isOpaque = false + panel.layout = BoxLayout(panel, BoxLayout.Y_AXIS) + + val title = JBLabel(item.question) + title.font = title.font.deriveFont(Font.BOLD) + title.border = JBUI.Borders.emptyBottom(2) + panel.add(title) + + val hintKey = if (item.multiple) "session.question.hint.multi" else "session.question.hint.single" + val hint = JBLabel(KiloBundle.message(hintKey)) + hint.foreground = UIUtil.getContextHelpForeground() + hint.border = JBUI.Borders.emptyBottom(6) + panel.add(hint) + + panel.add(optionList(item, set)) + + return panel + } + + private fun optionList(item: QuestionItem, set: MutableSet): JPanel { + val panel = JPanel() + panel.isOpaque = false + panel.layout = BoxLayout(panel, BoxLayout.Y_AXIS) + + if (item.multiple) { + for (opt in item.options) { + panel.add(checkboxRow(opt, set)) + } + } else { + val group = ButtonGroup() + for (opt in item.options) { + val row = radioRow(opt, set, group) + panel.add(row) + } + } + + return panel + } + + // ── Option rows ─────────────────────────────────────────────────────────── + + private fun radioRow(opt: QuestionOption, set: MutableSet, group: ButtonGroup): JPanel { + val radio = JBRadioButton(opt.label) + radio.font = radio.font.deriveFont(Font.BOLD) + radio.isSelected = opt.label in set + radio.isOpaque = false + group.add(radio) + + radio.addActionListener { + set.clear() + set.add(opt.label) + refreshNavButtons() + } + + return optionRow(radio, opt) + } + + private fun checkboxRow(opt: QuestionOption, set: MutableSet): JPanel { + val box = JBCheckBox(opt.label) + box.font = box.font.deriveFont(Font.BOLD) + box.isSelected = opt.label in set + box.isOpaque = false + + box.addActionListener { + if (!set.remove(opt.label)) set.add(opt.label) + refreshNavButtons() + } + + return optionRow(box, opt) + } + + private fun optionRow(toggle: AbstractButton, opt: QuestionOption): JPanel { + val row = JPanel(BorderLayout()) + row.isOpaque = false + row.border = JBUI.Borders.emptyBottom(2) + row.toolTipText = opt.description.ifBlank { null } + + if (opt.description.isNotBlank()) { + // Stack toggle (with label) on top, description label below + val col = JPanel() + col.isOpaque = false + col.layout = BoxLayout(col, BoxLayout.Y_AXIS) + col.add(toggle) + + val desc = JBLabel(opt.description) + desc.foreground = UIUtil.getContextHelpForeground() + desc.border = JBUI.Borders.emptyLeft(toggle.insets.left + 4) + col.add(desc) + row.add(col, BorderLayout.CENTER) + } else { + row.add(toggle, BorderLayout.CENTER) + } + + // make clicking anywhere on the row trigger the toggle + row.addMouseListener(object : java.awt.event.MouseAdapter() { + override fun mouseClicked(e: java.awt.event.MouseEvent) { + if (toggle.isEnabled) toggle.doClick() + } + }) + + return row + } + + // ── Footer: Dismiss + Next/Submit ───────────────────────────────────────── + + private fun footer(item: QuestionItem, set: MutableSet): JPanel { + val q = question ?: return JPanel() + val last = idx == q.items.size - 1 + + val row = JPanel(BorderLayout()) + row.isOpaque = false + row.border = JBUI.Borders.emptyTop(8) + + val dismiss = JButton(KiloBundle.message("session.question.dismiss")) + dismiss.addActionListener { doReject() } + row.add(dismiss, BorderLayout.WEST) + + val rightLabel = if (last) KiloBundle.message("session.question.submit") + else KiloBundle.message("session.question.next") + val right = JButton(rightLabel) + right.isEnabled = set.isNotEmpty() + right.addActionListener { + if (last) doReply() + else goForward() + } + row.add(right, BorderLayout.EAST) + + return row + } + + // ── Navigation ──────────────────────────────────────────────────────────── + + private fun goBack() { + if (idx <= 0) return + idx-- + render() + refresh() + } + + private fun goForward() { + val q = question ?: return + if (idx >= q.items.size - 1) return + if (selections[idx].isEmpty()) return + idx++ + render() + refresh() + } + + /** Refresh only the enabled state of nav buttons without a full re-render. */ + private fun refreshNavButtons() { + // Re-render is cheap and keeps state consistent; do a full render. + render() + refresh() + } + + // ── Submit / reject ─────────────────────────────────────────────────────── + + private fun doReply() { val id = requestId ?: return - reply(id, QuestionReplyDto(answers)) + reply(id, QuestionReplyDto(selections.map { it.toList() })) hideView() } 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 c176750e81c..f82a12f3b98 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -19,6 +19,11 @@ session.permission.allow=Allow session.permission.deny=Deny session.question.dismiss=Dismiss session.question.submit=Submit +session.question.next=Next +session.question.back=Back +session.question.summary={0} of {1} questions +session.question.hint.single=Select one answer +session.question.hint.multi=Select one or more answers session.status.considering=Considering next steps… session.status.thinking=Thinking… diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt index 776549be8b6..522fc6f5a26 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt @@ -3,9 +3,15 @@ package ai.kilocode.client.session.views import ai.kilocode.client.session.model.Question import ai.kilocode.client.session.model.QuestionItem import ai.kilocode.client.session.model.QuestionOption +import ai.kilocode.client.ui.HoverIcon import ai.kilocode.rpc.dto.QuestionReplyDto import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.JBCheckBox +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBRadioButton import java.awt.Container +import java.awt.Font +import javax.swing.AbstractButton import javax.swing.JButton @Suppress("UnstableApiUsage") @@ -23,6 +29,8 @@ class QuestionViewTest : BasePlatformTestCase() { ) } + // ------ empty question ------ + fun `test empty question hides view and clears stale request id`() { view.show( Question( @@ -47,6 +55,8 @@ class QuestionViewTest : BasePlatformTestCase() { assertTrue(rejects.isEmpty()) } + // ------ dismiss ------ + fun `test dismiss button uses bundle text and rejects question`() { view.show( Question( @@ -63,24 +73,50 @@ class QuestionViewTest : BasePlatformTestCase() { ) ) - buttons(view).first { it.text == "Dismiss" }.doClick() + button(view, "Dismiss").doClick() assertFalse(view.isVisible) assertEquals("req_1", rejects.single()) assertTrue(replies.isEmpty()) } - fun `test single question submit sends answer`() { + // ------ radio options ------ + + fun `test single question renders radio options`() { + view.show(singleSelectQuestion("req_r")) + + val radios = findAll(view) + assertEquals(2, radios.size) + assertEquals("Minimal", radios[0].text) + assertEquals("Balanced", radios[1].text) + assertTrue(findAll(view).isEmpty()) + } + + fun `test single question submit sends selected answer`() { + view.show(singleSelectQuestion("req_2")) + + // Select via radio button + findAll(view).first { it.text == "Minimal" }.doClick() + button(view, "Submit").doClick() + + assertFalse(view.isVisible) + assertEquals(1, replies.size) + assertEquals("req_2", replies.single().first) + assertEquals(listOf(listOf("Minimal")), replies.single().second.answers) + } + + // ------ option label and description ------ + + fun `test option row shows bold label and regular description`() { view.show( Question( - id = "req_2", + id = "desc_test", items = listOf( QuestionItem( - question = "Choose approach", + question = "How to proceed?", header = "Approach", options = listOf( - QuestionOption("Minimal", "Keep it simple"), - QuestionOption("Refactor", "Full refactor"), + QuestionOption("Minimal", "Smallest safe change"), ), multiple = false, custom = false, @@ -89,55 +125,86 @@ class QuestionViewTest : BasePlatformTestCase() { ) ) - buttons(view).first { it.text == "Minimal" }.doClick() - buttons(view).first { it.text == "Submit" }.doClick() + // The option label is the radio button text (bold) + val radio = findAll(view).first { it.text == "Minimal" } + assertTrue("option label should be bold", radio.font.isBold) - assertFalse(view.isVisible) - assertEquals(1, replies.size) - assertEquals("req_2", replies.single().first) - assertEquals(listOf(listOf("Minimal")), replies.single().second.answers) + // The description is a JBLabel with regular weight + val desc = findAll(view).firstOrNull { it.text == "Smallest safe change" } + assertNotNull("description label should be present", desc) + assertFalse("description should not be bold", desc!!.font.style == Font.BOLD) } - fun `test multi question submit sends all answers`() { - view.show( - Question( - id = "q_strategy", - items = listOf( - QuestionItem( - question = "Choose approach", - header = "Approach", - options = listOf( - QuestionOption("Minimal", "Keep it simple"), - QuestionOption("Refactor", "Full refactor"), - ), - multiple = false, - custom = false, - ), - QuestionItem( - question = "Choose test level", - header = "Test Level", - options = listOf( - QuestionOption("Unit", "Unit tests"), - QuestionOption("Integration", "Integration tests"), - ), - multiple = false, - custom = false, - ), - ), - ) - ) + // ------ multi-question navigation ------ - buttons(view).first { it.text == "Minimal" }.doClick() - buttons(view).first { it.text == "Unit" }.doClick() - buttons(view).first { it.text == "Submit" }.doClick() + fun `test multi question shows one question at a time and navigates`() { + view.show(twoItemQuestion("q_nav")) + + // First question shown, second not + assertLabelsContain(view, "Choose approach") + assertLabelsDoNotContain(view, "Choose test level") + assertLabelsContain(view, "1 of 2 questions") + + // Select an answer on first question, then click Next + findAll(view).first { it.text == "Minimal" }.doClick() + button(view, "Next").doClick() + + // Second question shown, first not + assertLabelsContain(view, "Choose test level") + assertLabelsDoNotContain(view, "Choose approach") + assertLabelsContain(view, "2 of 2 questions") + + // Select answer on second question, submit + findAll(view).first { it.text == "Unit" }.doClick() + button(view, "Submit").doClick() assertFalse(view.isVisible) assertEquals(1, replies.size) - assertEquals("q_strategy", replies.single().first) + assertEquals("q_nav", replies.single().first) assertEquals(listOf(listOf("Minimal"), listOf("Unit")), replies.single().second.answers) } - fun `test multiple selection item toggles options`() { + fun `test back preserves previous selection`() { + view.show(twoItemQuestion("q_back")) + + // Answer first question + findAll(view).first { it.text == "Minimal" }.doClick() + button(view, "Next").doClick() + + // Go back via header nav icon + navButton(view, "Back").doClick() + + // First question visible again, selection preserved + assertLabelsContain(view, "Choose approach") + assertLabelsContain(view, "1 of 2 questions") + val radios = findAll(view) + assertTrue("Minimal should still be selected", radios.first { it.text == "Minimal" }.isSelected) + + // Change selection to Balanced, go forward, submit + findAll(view).first { it.text == "Balanced" }.doClick() + button(view, "Next").doClick() + findAll(view).first { it.text == "Unit" }.doClick() + button(view, "Submit").doClick() + + assertEquals(listOf(listOf("Balanced"), listOf("Unit")), replies.single().second.answers) + } + + fun `test next is disabled until current question is answered`() { + view.show(twoItemQuestion("q_disabled")) + + val next = button(view, "Next") + assertFalse("Next should be disabled before selection", next.isEnabled) + + findAll(view).first { it.text == "Minimal" }.doClick() + + // Re-render happened; get fresh reference + val nextAfter = button(view, "Next") + assertTrue("Next should be enabled after selection", nextAfter.isEnabled) + } + + // ------ multi-select checkboxes ------ + + fun `test multiple selection item uses checkboxes and toggles options`() { view.show( Question( id = "req_3", @@ -157,11 +224,15 @@ class QuestionViewTest : BasePlatformTestCase() { ) ) - buttons(view).first { it.text == "A" }.doClick() - buttons(view).first { it.text == "B" }.doClick() - // Toggle B off - buttons(view).first { it.text == "B" }.doClick() - buttons(view).first { it.text == "Submit" }.doClick() + val boxes = findAll(view) + assertEquals(3, boxes.size) + assertTrue(findAll(view).isEmpty()) + + boxes.first { it.text == "A" }.doClick() + boxes.first { it.text == "B" }.doClick() + // Toggle B off — need fresh refs after re-render + findAll(view).first { it.text == "B" }.doClick() + button(view, "Submit").doClick() assertFalse(view.isVisible) assertEquals(1, replies.size) @@ -169,8 +240,88 @@ class QuestionViewTest : BasePlatformTestCase() { assertEquals(listOf(listOf("A")), replies.single().second.answers) } - private fun buttons(root: Container): List = root.components.flatMap { comp -> - val item = if (comp is JButton) listOf(comp) else emptyList() - if (comp is Container) item + buttons(comp) else item + // ------ helpers ------ + + /** + * Find a [JButton] by button text — covers footer buttons (Dismiss, Next, Submit). + * For icon-only nav buttons (Back/Forward) that use tooltip, use [navButton]. + */ + private fun button(root: Container, text: String): JButton = + findAll(root).first { it.text == text } + + /** Find a [HoverIcon] nav button by tooltip text (Back / Next nav arrows). */ + private fun navButton(root: Container, tooltip: String): HoverIcon = + findAll(root).first { it.toolTipText == tooltip } + + private fun singleSelectQuestion(id: String) = Question( + id = id, + items = listOf( + QuestionItem( + question = "Choose approach", + header = "Approach", + options = listOf( + QuestionOption("Minimal", "Smallest safe change"), + QuestionOption("Balanced", "Focused implementation with tests"), + ), + multiple = false, + custom = false, + ) + ), + ) + + private fun twoItemQuestion(id: String) = Question( + id = id, + items = listOf( + QuestionItem( + question = "Choose approach", + header = "Approach", + options = listOf( + QuestionOption("Minimal", "Smallest safe change"), + QuestionOption("Balanced", "Focused implementation"), + ), + multiple = false, + custom = false, + ), + QuestionItem( + question = "Choose test level", + header = "Test Level", + options = listOf( + QuestionOption("Unit", "Unit tests"), + QuestionOption("Integration", "Integration tests"), + ), + multiple = false, + custom = false, + ), + ), + ) + + private fun assertLabelsContain(root: Container, text: String) { + val found = findAll(root).any { it.text == text } + assertTrue("Expected label '$text' to be present", found) + } + + private fun assertLabelsDoNotContain(root: Container, text: String) { + val found = findAll(root).any { it.text == text } + assertFalse("Expected label '$text' to be absent, but it was found", found) + } + + private inline fun findAll(root: Container): List = findAllCls(root, T::class.java) + + /** + * Recursively find all components of type [cls], but do NOT recurse into + * [AbstractButton] subtypes — buttons may have internal sub-components + * (e.g. IntelliJ UI delegate children) that would produce spurious matches. + */ + private fun findAllCls(root: Container, cls: Class): List { + val result = mutableListOf() + if (cls.isInstance(root)) result.add(cls.cast(root)) + for (child in root.components) { + if (cls.isInstance(child)) result.add(cls.cast(child)) + // Do not recurse into button internals to avoid double-counting + if (child is Container && child !is AbstractButton) { + result.addAll(findAllCls(child, cls)) + } + } + return result } }