diff --git a/.changeset/jetbrains-scrollable-prompts.md b/.changeset/jetbrains-scrollable-prompts.md new file mode 100644 index 00000000000..0a923961601 --- /dev/null +++ b/.changeset/jetbrains-scrollable-prompts.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Render active question and permission prompts inside the scrollable JetBrains chat transcript. 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 f30a434ee5d..5194fa7dfd4 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 @@ -12,9 +12,7 @@ import ai.kilocode.client.session.ui.LoadingPanel 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.PermissionPanel import ai.kilocode.client.session.ui.prompt.PromptPanel -import ai.kilocode.client.session.ui.QuestionPanel import ai.kilocode.client.session.ui.SessionRootPanel import ai.kilocode.client.session.ui.SessionMessageListPanel import ai.kilocode.client.session.ui.header.SessionHeaderPanel @@ -23,6 +21,8 @@ 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.views.PermissionView +import ai.kilocode.client.session.views.QuestionView import ai.kilocode.log.ChatLogSummary import ai.kilocode.log.KiloLog import com.intellij.ide.ui.LafManagerListener @@ -35,7 +35,6 @@ import com.intellij.openapi.util.registry.Registry import kotlinx.coroutines.CoroutineScope import java.awt.BorderLayout import javax.swing.BoxLayout -import javax.swing.BoxLayout.Y_AXIS import javax.swing.JComponent import javax.swing.JPanel @@ -97,8 +96,8 @@ class SessionUi( internal lateinit var scroll: SessionScroll - private lateinit var question: QuestionPanel - private lateinit var permission: PermissionPanel + private lateinit var question: QuestionView + private lateinit var permission: PermissionView private lateinit var connection: ConnectionPanel private lateinit var prompt: PromptPanel @@ -146,12 +145,17 @@ class SessionUi( load = LoadingPanel() progressBody = load - messageBody = SessionMessageListPanel(controller.model, this) + question = QuestionView( + reply = { id, dto -> controller.replyQuestion(id, dto) }, + reject = { id -> controller.rejectQuestion(id) }, + ) + permission = PermissionView( + reply = { id, dto -> controller.replyPermission(id, dto) }, + ) + messageBody = SessionMessageListPanel(controller.model, this, question, permission) header = SessionHeaderPanel(controller, this) scroll = SessionScroll(root, sessionContent, messageBody, blankBody) - question = QuestionPanel(controller) - permission = PermissionPanel(controller) connection = ConnectionPanel(this, controller) prompt = PromptPanel( @@ -163,12 +167,8 @@ class SessionUi( sessionContent.add(header, BorderLayout.NORTH) sessionContent.add(scroll.component, BorderLayout.CENTER) root.content.add(sessionContent, BorderLayout.CENTER) - // Dock panels stay in normal flow so each visible state takes layout space - // above the prompt. root.content.add(JPanel().apply { - this.layout = BoxLayout(this, Y_AXIS) - add(question) - add(permission) + layout = BoxLayout(this, BoxLayout.Y_AXIS) add(connection) add(prompt) }, BorderLayout.SOUTH) @@ -321,22 +321,6 @@ class SessionUi( private fun onStateChanged(state: SessionState) { prompt.setBusy(state.isBusy()) - when (state) { - is SessionState.AwaitingQuestion -> { - permission.hidePanel() - question.show(state.question) - } - - is SessionState.AwaitingPermission -> { - question.hidePanel() - permission.show(state.permission) - } - - else -> { - question.hidePanel() - permission.hidePanel() - } - } refresh() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/PermissionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/PermissionPanel.kt deleted file mode 100644 index 49e8b14261c..00000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/PermissionPanel.kt +++ /dev/null @@ -1,83 +0,0 @@ -package ai.kilocode.client.session.ui - -import ai.kilocode.client.plugin.KiloBundle -import ai.kilocode.client.session.model.Permission -import ai.kilocode.client.session.ui.style.Dock -import ai.kilocode.client.session.controller.SessionController -import ai.kilocode.rpc.dto.PermissionReplyDto -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.panel -import com.intellij.util.ui.components.BorderLayoutPanel -import java.awt.BorderLayout - -/** - * Docked permission panel — shown above the prompt when the session is in - * [ai.kilocode.client.session.model.SessionState.AwaitingPermission]. - * - * The inner layout is built via Kotlin UI DSL inside [show] so it reflects - * the current permission's tool, patterns, and optional message. - * - * Layout (mirrors VS Code's PermissionDock): - * ``` - * ┌─────────────────────────────────────────┐ - * │ ⚠ Permission request │ - * │ Tool: edit • Patterns: *.kt │ - * │ │ - * │ [Allow] [Deny] │ - * └─────────────────────────────────────────┘ - * ``` - */ -class PermissionPanel( - private val controller: SessionController, -) : BorderLayoutPanel() { - - private lateinit var requestId: String - - init { - border = Dock.warning() - isVisible = false - } - - /** Populate the panel for [permission] and make it visible. */ - fun show(permission: Permission) { - requestId = permission.id - val patterns = permission.patterns.joinToString(", ").ifEmpty { "*" } - - removeAll() - add(panel { - row { - icon(AllIcons.General.Warning).gap(RightGap.SMALL) - label(KiloBundle.message("session.permission.title")).bold() - } - row { - label(KiloBundle.message("session.permission.meta", permission.name, patterns)) - } - val msg = permission.message - if (!msg.isNullOrBlank()) { - row { - comment(msg) - } - } - row { - button(KiloBundle.message("session.permission.allow")) { decide("once") }.gap(RightGap.SMALL) - button(KiloBundle.message("session.permission.deny")) { decide("reject") } - }.layout(RowLayout.INDEPENDENT) - }, BorderLayout.CENTER) - - isVisible = true - revalidate() - repaint() - } - - /** Hide this panel. */ - fun hidePanel() { - isVisible = false - } - - private fun decide(reply: String) { - controller.replyPermission(requestId, PermissionReplyDto(reply = reply)) - hidePanel() - } -} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt index bc348f09079..80c2fe8ce91 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt @@ -2,10 +2,13 @@ package ai.kilocode.client.session.ui import ai.kilocode.client.session.model.SessionModel import ai.kilocode.client.session.model.SessionModelEvent +import ai.kilocode.client.session.model.SessionState 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.session.views.MessageView +import ai.kilocode.client.session.views.PermissionView +import ai.kilocode.client.session.views.QuestionView import ai.kilocode.client.session.views.TurnView import com.intellij.openapi.Disposable import com.intellij.util.ui.JBUI @@ -29,11 +32,17 @@ import com.intellij.util.ui.JBUI * bottom of the transcript inside the scroll pane and shows a spinner while * the session is busy. * + * Optional [question] and [permission] views are kept immediately before + * [progress] in component order and shown/hidden in response to + * [SessionModelEvent.StateChanged]. + * * All method calls must happen on the EDT. */ class SessionMessageListPanel( private val model: SessionModel, parent: Disposable, + private val question: QuestionView? = null, + private val permission: PermissionView? = null, ) : SessionLayoutPanel( JBUI.scale(SessionUiStyle.SessionLayout.GAP), JBUI.insets( @@ -90,12 +99,16 @@ class SessionMessageListPanel( is SessionModelEvent.HistoryLoaded -> rebuild() is SessionModelEvent.Cleared -> clear() + is SessionModelEvent.StateChanged -> { + syncActive(event.state) + anchorFooter() + refresh() + } + // Message events: structural changes are handled via turn events above. - // State/diff/todos changes are handled by other panels in SessionUi. is SessionModelEvent.MessageAdded, is SessionModelEvent.MessageUpdated, is SessionModelEvent.MessageRemoved, - is SessionModelEvent.StateChanged, is SessionModelEvent.DiffUpdated, is SessionModelEvent.TodosUpdated, is SessionModelEvent.SessionUpdated, @@ -217,6 +230,7 @@ class SessionMessageListPanel( add(tv) } + syncActive(model.state) anchorFooter() refresh() } @@ -226,13 +240,47 @@ class SessionMessageListPanel( msgToTurn.clear() msgToView.clear() removeAll() + syncActive(model.state) anchorFooter() refresh() } - /** Re-insert [progress] as the last child so it always renders after all turn views. */ + /** + * Show or hide active question/permission views based on [state]. + * Both views are always kept as children of this panel (added in [anchorFooter]), + * but visibility is controlled here. + */ + private fun syncActive(state: SessionState = model.state) { + when (state) { + is SessionState.AwaitingQuestion -> { + permission?.hideView() + question?.show(state.question) + } + is SessionState.AwaitingPermission -> { + question?.hideView() + permission?.show(state.permission) + } + else -> { + question?.hideView() + permission?.hideView() + } + } + } + + /** + * Re-insert [question], [permission], and [progress] as the last children + * so active views always render after all turn views, and progress is last. + * + * Both active views are added even when invisible — [SessionLayout] skips + * invisible children, so no extra space is consumed, and the component tree + * remains stable for tests. + */ private fun anchorFooter() { + if (question != null) remove(question) + if (permission != null) remove(permission) remove(progress) + if (question != null) add(question) + if (permission != null) add(permission) add(progress) } @@ -254,6 +302,8 @@ class SessionMessageListPanel( override fun applyStyle(style: SessionEditorStyle) { this.style = style for (view in turnViews.values) view.applyStyle(style) + question?.applyStyle(style) + permission?.applyStyle(style) progress.applyStyle(style) refresh() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt index 9ace86801c3..6cfdc93961e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt @@ -105,20 +105,10 @@ object SessionUiStyle { } } -/** Border presets for question, permission, and connection dock panels. */ +/** Border presets for connection dock panel. */ object Dock { fun banner(): Border = JBUI.Borders.compound( JBUI.Borders.customLineTop(SessionUiStyle.View.line()), JBUI.Borders.empty(UiStyle.Gap.sm(), UiStyle.Gap.lg(), 0, UiStyle.Gap.lg()), )!! - - fun neutral(): Border = JBUI.Borders.compound( - JBUI.Borders.customLine(SessionUiStyle.View.line(), 1), - JBUI.Borders.empty(UiStyle.Gap.lg(), UiStyle.Gap.pad()), - )!! - - fun warning(): Border = JBUI.Borders.compound( - customLine(UiStyle.Colors.warningLabelForeground(), 1), - JBUI.Borders.empty(UiStyle.Gap.lg(), UiStyle.Gap.pad()), - )!! } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PermissionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PermissionView.kt new file mode 100644 index 00000000000..be38165e41a --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PermissionView.kt @@ -0,0 +1,100 @@ +package ai.kilocode.client.session.views + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.model.Permission +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.rpc.dto.PermissionReplyDto +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.panel +import com.intellij.util.ui.components.BorderLayoutPanel +import java.awt.BorderLayout + +/** + * Transcript-style permission 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.AwaitingPermission]. + * + * Unlike the old docked [ai.kilocode.client.session.ui.PermissionPanel], this view lives inside + * the scrollable transcript so the user can scroll through prior messages while a permission is pending. + */ +class PermissionView( + private val reply: (String, PermissionReplyDto) -> Unit, +) : BorderLayoutPanel(), SessionEditorStyleTarget, SessionView { + override val sessionViewKind = SessionView.Kind.Default + + private var requestId: String? = null + private var style = SessionEditorStyle.current() + + init { + isOpaque = false + isVisible = false + } + + /** Populate the view for [permission] and make it visible. */ + fun show(permission: Permission) { + requestId = permission.id + val patterns = permission.patterns.joinToString(", ").ifEmpty { "*" } + + removeAll() + + val card = BorderLayoutPanel() + card.isOpaque = true + card.background = SessionUiStyle.View.surface() + card.border = SessionUiStyle.View.card() + + card.add(panel { + row { + icon(AllIcons.General.Warning).gap(RightGap.SMALL) + label(KiloBundle.message("session.permission.title")).bold() + } + row { + label(KiloBundle.message("session.permission.meta", permission.name, patterns)) + } + val msg = permission.message + if (!msg.isNullOrBlank()) { + row { + comment(msg) + } + } + row { + button(KiloBundle.message("session.permission.allow")) { decide("once") }.gap(RightGap.SMALL) + button(KiloBundle.message("session.permission.deny")) { decide("reject") } + }.layout(RowLayout.INDEPENDENT) + }.also { it.isOpaque = false }, 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() + } + + override fun applyStyle(style: SessionEditorStyle) { + this.style = style + } + + private fun decide(value: String) { + val id = requestId ?: return + reply(id, PermissionReplyDto(reply = value)) + hideView() + } + + private fun refresh() { + revalidate() + repaint() + parent?.revalidate() + parent?.repaint() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/QuestionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/QuestionView.kt similarity index 58% rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/QuestionPanel.kt rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/QuestionView.kt index 582d566096d..a5dd2e1c2bf 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/QuestionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/QuestionView.kt @@ -1,10 +1,12 @@ -package ai.kilocode.client.session.ui +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.ui.style.Dock -import ai.kilocode.client.session.controller.SessionController +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.rpc.dto.QuestionReplyDto import com.intellij.icons.AllIcons import com.intellij.ui.dsl.builder.RightGap @@ -15,31 +17,31 @@ import com.intellij.util.ui.components.BorderLayoutPanel import java.awt.BorderLayout /** - * Docked question panel — shown above the prompt when the session is in + * 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]. * - * Renders all [Question.items] with per-item option selection. - * For `multiple = false` items, selecting an option replaces the previous choice. - * For `multiple = true` items, option buttons toggle membership. - * - * A single Submit button at the bottom sends all collected answers once each - * item has at least one answer selected. + * 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. */ -class QuestionPanel( - private val controller: SessionController, -) : BorderLayoutPanel() { +class QuestionView( + private val reply: (String, QuestionReplyDto) -> Unit, + private val reject: (String) -> Unit, +) : BorderLayoutPanel(), SessionEditorStyleTarget, SessionView { + override val sessionViewKind = SessionView.Kind.Default private var requestId: String? = null + private var style = SessionEditorStyle.current() init { - border = Dock.neutral() + isOpaque = false isVisible = false } - /** Populate the panel for all items in [question] and make it visible. */ + /** Populate the view for all items in [question] and make it visible. */ fun show(question: Question) { if (question.items.isEmpty()) { - hidePanel() + hideView() return } requestId = question.id @@ -48,7 +50,13 @@ class QuestionPanel( val selections = Array(question.items.size) { mutableSetOf() } removeAll() - add(panel { + + val card = BorderLayoutPanel() + 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 { @@ -71,22 +79,28 @@ class QuestionPanel( row { button(KiloBundle.message("session.question.submit")) { - reply(selections.map { it.toList() }) + doReply(selections.map { it.toList() }) }.gap(RightGap.SMALL) - button(KiloBundle.message("session.question.dismiss")) { reject() } + button(KiloBundle.message("session.question.dismiss")) { doReject() } }.layout(RowLayout.INDEPENDENT).topGap(TopGap.SMALL) - }, BorderLayout.CENTER) + }.also { it.isOpaque = false }, BorderLayout.CENTER) + + add(card, BorderLayout.CENTER) isVisible = true - revalidate() - repaint() + refresh() } - /** Hide this panel. */ - fun hidePanel() { + /** Hide this view and clear the active request id. */ + fun hideView() { requestId = null removeAll() isVisible = false + refresh() + } + + override fun applyStyle(style: SessionEditorStyle) { + this.style = style } private fun toggleOption( @@ -104,15 +118,22 @@ class QuestionPanel( } } - private fun reply(answers: List>) { + private fun doReply(answers: List>) { val id = requestId ?: return - controller.replyQuestion(id, QuestionReplyDto(answers)) - hidePanel() + reply(id, QuestionReplyDto(answers)) + hideView() } - private fun reject() { + private fun doReject() { val id = requestId ?: return - controller.rejectQuestion(id) - hidePanel() + reject(id) + hideView() + } + + private fun refresh() { + revalidate() + repaint() + parent?.revalidate() + parent?.repaint() } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt index ce71341b4b7..48194c04e20 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt @@ -10,13 +10,13 @@ import ai.kilocode.client.session.model.SessionState 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.PermissionPanel import ai.kilocode.client.session.ui.prompt.PromptPanel -import ai.kilocode.client.session.ui.QuestionPanel 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.controller.SessionControllerEvent +import ai.kilocode.client.session.views.PermissionView +import ai.kilocode.client.session.views.QuestionView import ai.kilocode.rpc.dto.MessageWithPartsDto import com.intellij.ui.components.JBScrollPane import javax.swing.JLayeredPane @@ -34,10 +34,8 @@ class SessionUiLayoutTest : SessionUiTestBase() { assertEquals(JLayeredPane.PALETTE_LAYER, root.getLayer(root.overlay)) } - fun `test connection panel is docked between permission and prompt`() { + fun `test bottom stack contains connection and prompt only`() { val root = find(ui) - val question = find(ui) - val permission = find(ui) val connection = find(ui) val prompt = find(ui) val stack = prompt.parent @@ -45,7 +43,19 @@ class SessionUiLayoutTest : SessionUiTestBase() { assertSame(root.content, stack.parent) assertSame(stack, connection.parent) assertEquals(1, root.overlay.componentCount) - assertEquals(listOf(question, permission, connection, prompt), stack.components.toList()) + assertEquals(listOf(connection, prompt), stack.components.toList()) + } + + fun `test active views are children of message list panel`() { + ui = newUi(id = "ses_test") + settle() + + val messages = find(ui) + val qv = find(ui) + val pv = find(ui) + + assertSame(messages, qv.parent) + assertSame(messages, pv.parent) } fun `test header is docked above shared scroll pane and hidden while empty`() { @@ -80,42 +90,68 @@ class SessionUiLayoutTest : SessionUiTestBase() { assertTrue(connection.y + connection.height <= prompt.y) } - fun `test connection panel moves after visible question panel`() { - val connection = find(ui) - val question = find(ui) - val prompt = find(ui) - + fun `test connection panel is unaffected by active question view`() { + ui = newUi(id = "ses_test") + settle() showConnection() layout() - assertFalse(question.isVisible) + val connection = find(ui) + val prompt = find(ui) val top = connection.y controller().model.setState(questionStateChanged()) layout() - assertTrue(question.isVisible) - assertTrue(question.y < connection.y) - assertTrue(top < connection.y) + assertTrue(find(ui).isVisible) + assertSame(find(ui), find(ui).parent) + assertEquals(top, connection.y) assertTrue(connection.y + connection.height <= prompt.y) + assertSame(find(ui), scrollView()) } - fun `test connection panel moves after visible permission panel`() { - val connection = find(ui) - val permission = find(ui) - val prompt = find(ui) - + fun `test connection panel is unaffected by active permission view`() { + ui = newUi(id = "ses_test") + settle() showConnection() layout() - assertFalse(permission.isVisible) + val connection = find(ui) + val prompt = find(ui) val top = connection.y controller().model.setState(permissionStateChanged()) layout() - assertTrue(permission.isVisible) - assertTrue(permission.y < connection.y) - assertTrue(top < connection.y) + assertTrue(find(ui).isVisible) + assertSame(find(ui), find(ui).parent) + assertEquals(top, connection.y) assertTrue(connection.y + connection.height <= prompt.y) + assertSame(find(ui), scrollView()) + } + + fun `test active question view renders inside message scroll view`() { + ui = newUi(id = "ses_test") + settle() + + controller().model.setState(questionStateChanged()) + layout() + + assertSame(find(ui), scrollView()) + assertTrue(find(ui).isVisible) + assertSame(find(ui), find(ui).parent) + assertTrue(find(ui).parent !== find(ui).parent) + } + + fun `test active permission view renders inside message scroll view`() { + ui = newUi(id = "ses_test") + settle() + + controller().model.setState(permissionStateChanged()) + layout() + + assertSame(find(ui), scrollView()) + assertTrue(find(ui).isVisible) + assertSame(find(ui), find(ui).parent) + assertTrue(find(ui).parent !== find(ui).parent) } fun `test empty and message bodies share the same scroll pane`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PermissionPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PermissionPanelTest.kt deleted file mode 100644 index 4f39bb9810a..00000000000 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PermissionPanelTest.kt +++ /dev/null @@ -1,115 +0,0 @@ -package ai.kilocode.client.session.ui - -import ai.kilocode.client.app.KiloAppService -import ai.kilocode.client.app.KiloSessionService -import ai.kilocode.client.app.KiloWorkspaceService -import ai.kilocode.client.app.Workspace -import ai.kilocode.client.session.model.Permission -import ai.kilocode.client.session.model.PermissionMeta -import ai.kilocode.client.session.SessionRef -import ai.kilocode.client.session.controller.SessionController -import ai.kilocode.client.testing.FakeAppRpcApi -import ai.kilocode.client.testing.FakeSessionRpcApi -import ai.kilocode.client.testing.FakeWorkspaceRpcApi -import ai.kilocode.rpc.dto.KiloAppStateDto -import ai.kilocode.rpc.dto.KiloAppStatusDto -import ai.kilocode.rpc.dto.KiloWorkspaceStateDto -import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto -import com.intellij.openapi.Disposable -import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.util.Disposer -import com.intellij.testFramework.fixtures.BasePlatformTestCase -import com.intellij.util.ui.UIUtil -import com.intellij.util.ui.components.BorderLayoutPanel -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay -import kotlinx.coroutines.runBlocking -import java.awt.Container -import javax.swing.AbstractButton - -@Suppress("UnstableApiUsage") -class PermissionPanelTest : BasePlatformTestCase() { - - private lateinit var parent: Disposable - private lateinit var scope: CoroutineScope - private lateinit var rpc: FakeSessionRpcApi - private lateinit var app: KiloAppService - private lateinit var workspaces: KiloWorkspaceService - private lateinit var workspace: Workspace - private lateinit var controller: SessionController - private lateinit var panel: PermissionPanel - - override fun setUp() { - super.setUp() - parent = Disposer.newDisposable("permission-panel") - scope = CoroutineScope(SupervisorJob()) - rpc = FakeSessionRpcApi() - val sessions = KiloSessionService(project, scope, rpc) - val api = FakeAppRpcApi().also { it.state.value = KiloAppStateDto(KiloAppStatusDto.READY) } - val work = FakeWorkspaceRpcApi().also { - it.state.value = KiloWorkspaceStateDto(status = KiloWorkspaceStatusDto.READY) - } - app = KiloAppService(scope, api) - workspaces = KiloWorkspaceService(scope, work) - workspace = workspaces.workspace("/test") - controller = SessionController(parent, SessionRef.Local("ses_test"), sessions, workspace, app, scope, BorderLayoutPanel()) - panel = PermissionPanel(controller) - } - - override fun tearDown() { - try { - Disposer.dispose(parent) - scope.cancel() - } finally { - super.tearDown() - } - } - - fun `test allow button uses bundle text and replies once`() { - panel.show(permission()) - - buttons(panel).first { it.text == "Allow" }.doClick() - flush() - - assertFalse(panel.isVisible) - assertEquals("perm1", rpc.permissionReplies.single().first) - assertEquals("once", rpc.permissionReplies.single().third.reply) - } - - fun `test deny button uses bundle text and rejects`() { - panel.show(permission()) - - buttons(panel).first { it.text == "Deny" }.doClick() - flush() - - assertFalse(panel.isVisible) - assertEquals("perm1", rpc.permissionReplies.single().first) - assertEquals("reject", rpc.permissionReplies.single().third.reply) - } - - private fun permission() = Permission( - id = "perm1", - sessionId = "ses_test", - name = "edit", - patterns = listOf("*.kt"), - always = emptyList(), - meta = PermissionMeta(), - message = "Review file changes", - ) - - private fun buttons(root: Container): List = root.components.flatMap { comp -> - val item = if (comp is AbstractButton) listOf(comp) else emptyList() - if (comp is Container) item + buttons(comp) else item - } - - private fun flush() = runBlocking { - repeat(5) { - delay(100) - ApplicationManager.getApplication().invokeAndWait { - UIUtil.dispatchAllInvocationEvents() - } - } - } -} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/QuestionPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/QuestionPanelTest.kt deleted file mode 100644 index 8edb2358444..00000000000 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/QuestionPanelTest.kt +++ /dev/null @@ -1,240 +0,0 @@ -package ai.kilocode.client.session.ui - -import ai.kilocode.client.app.KiloAppService -import ai.kilocode.client.app.KiloSessionService -import ai.kilocode.client.app.KiloWorkspaceService -import ai.kilocode.client.app.Workspace -import ai.kilocode.client.session.SessionRef -import ai.kilocode.client.session.controller.SessionController -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.testing.FakeAppRpcApi -import ai.kilocode.client.testing.FakeSessionRpcApi -import ai.kilocode.client.testing.FakeWorkspaceRpcApi -import ai.kilocode.rpc.dto.KiloAppStateDto -import ai.kilocode.rpc.dto.KiloAppStatusDto -import ai.kilocode.rpc.dto.KiloWorkspaceStateDto -import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto -import com.intellij.openapi.Disposable -import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.util.Disposer -import com.intellij.testFramework.fixtures.BasePlatformTestCase -import com.intellij.util.ui.UIUtil -import com.intellij.util.ui.components.BorderLayoutPanel -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay -import kotlinx.coroutines.runBlocking -import java.awt.Container -import javax.swing.JButton - -@Suppress("UnstableApiUsage") -class QuestionPanelTest : BasePlatformTestCase() { - - private lateinit var parent: Disposable - private lateinit var scope: CoroutineScope - private lateinit var rpc: FakeSessionRpcApi - private lateinit var app: KiloAppService - private lateinit var workspaces: KiloWorkspaceService - private lateinit var workspace: Workspace - private lateinit var controller: SessionController - private lateinit var panel: QuestionPanel - - override fun setUp() { - super.setUp() - parent = Disposer.newDisposable("question-panel") - scope = CoroutineScope(SupervisorJob()) - rpc = FakeSessionRpcApi() - val sessions = KiloSessionService(project, scope, rpc) - val appRpc = FakeAppRpcApi().also { it.state.value = KiloAppStateDto(KiloAppStatusDto.READY) } - val workspaceRpc = FakeWorkspaceRpcApi().also { - it.state.value = KiloWorkspaceStateDto(status = KiloWorkspaceStatusDto.READY) - } - app = KiloAppService(scope, appRpc) - workspaces = KiloWorkspaceService(scope, workspaceRpc) - workspace = workspaces.workspace("/test") - val root = BorderLayoutPanel() - controller = SessionController(parent, SessionRef.Local("ses_test"), sessions, workspace, app, scope, root) - panel = QuestionPanel(controller) - } - - override fun tearDown() { - try { - Disposer.dispose(parent) - scope.cancel() - } finally { - super.tearDown() - } - } - - fun `test empty question hides panel and clears stale request id`() { - panel.show( - Question( - id = "req_old", - items = listOf( - QuestionItem( - question = "Pick one", - header = "Header", - options = listOf(QuestionOption("Yes", "desc")), - multiple = false, - custom = true, - ) - ), - ) - ) - assertTrue(panel.isVisible) - - panel.show(Question(id = "req_new", items = emptyList())) - - assertFalse(panel.isVisible) - assertEquals(0, panel.componentCount) - assertTrue(rpc.questionReplies.isEmpty()) - assertTrue(rpc.questionRejects.isEmpty()) - } - - fun `test dismiss button uses bundle text and rejects question`() { - panel.show( - Question( - id = "req_1", - items = listOf( - QuestionItem( - question = "Pick one", - header = "Header", - options = listOf(QuestionOption("Yes", "desc")), - multiple = false, - custom = true, - ) - ), - ) - ) - - val button = buttons(panel).first { it.text == "Dismiss" } - button.doClick() - flush() - - assertFalse(panel.isVisible) - assertEquals("req_1", rpc.questionRejects.single().first) - } - - fun `test single question submit sends answer`() { - panel.show( - Question( - id = "req_2", - items = listOf( - QuestionItem( - question = "Choose approach", - header = "Approach", - options = listOf( - QuestionOption("Minimal", "Keep it simple"), - QuestionOption("Refactor", "Full refactor"), - ), - multiple = false, - custom = false, - ) - ), - ) - ) - - buttons(panel).first { it.text == "Minimal" }.doClick() - buttons(panel).first { it.text == "Submit" }.doClick() - flush() - - assertFalse(panel.isVisible) - assertReply("req_2 /test [[Minimal]]", rpc.questionReplies) - } - - fun `test multi question submit sends all answers`() { - panel.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, - ), - ), - ) - ) - - buttons(panel).first { it.text == "Minimal" }.doClick() - buttons(panel).first { it.text == "Unit" }.doClick() - buttons(panel).first { it.text == "Submit" }.doClick() - flush() - - assertFalse(panel.isVisible) - assertReply("q_strategy /test [[Minimal],[Unit]]", rpc.questionReplies) - } - - fun `test multiple selection item toggles options`() { - panel.show( - Question( - id = "req_3", - items = listOf( - QuestionItem( - question = "Select features", - header = "Features", - options = listOf( - QuestionOption("A", "Feature A"), - QuestionOption("B", "Feature B"), - QuestionOption("C", "Feature C"), - ), - multiple = true, - custom = false, - ) - ), - ) - ) - - buttons(panel).first { it.text == "A" }.doClick() - buttons(panel).first { it.text == "B" }.doClick() - // Toggle B off - buttons(panel).first { it.text == "B" }.doClick() - buttons(panel).first { it.text == "Submit" }.doClick() - flush() - - assertFalse(panel.isVisible) - assertReply("req_3 /test [[A]]", rpc.questionReplies) - } - - private fun assertReply(expected: String, replies: List>) { - val act = replies.joinToString("\n") { (id, dir, reply) -> - val answers = reply.answers.joinToString(",", "[", "]") { inner -> - inner.joinToString(",", "[", "]") - } - "$id $dir $answers" - } - assertEquals(expected, act) - } - - 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 - } - - private fun flush() = runBlocking { - repeat(5) { - delay(100) - ApplicationManager.getApplication().invokeAndWait { - UIUtil.dispatchAllInvocationEvents() - } - } - } -} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt index 96fbc965366..1a92c89f76e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt @@ -1,7 +1,15 @@ package ai.kilocode.client.session.ui +import ai.kilocode.client.session.model.Permission +import ai.kilocode.client.session.model.PermissionMeta +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.model.SessionModel +import ai.kilocode.client.session.model.SessionState import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.views.PermissionView +import ai.kilocode.client.session.views.QuestionView import ai.kilocode.client.session.views.TextView import ai.kilocode.rpc.dto.MessageDto import ai.kilocode.rpc.dto.MessageTimeDto @@ -10,6 +18,7 @@ import ai.kilocode.rpc.dto.PartDto import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.awt.Container /** * Tests for [SessionMessageListPanel] — structural and index integrity. @@ -257,8 +266,110 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { assertTrue(text.md.overrideSheet().contains("25pt")) } + // ------ active view tests ------ + + fun `test active question is anchored before progress footer`() { + val item = panelWithPrompts() + model.upsertMessage(msg("u1", "user")) + model.setState(SessionState.AwaitingQuestion(question())) + + val qv = find(item)!! + val pv = find(item)!! + val comps = item.components.toList() + + assertTrue(qv.isVisible) + assertFalse(pv.isVisible) + assertSame(item.progress, comps.last()) + assertTrue(comps.indexOf(qv) < comps.indexOf(item.progress)) + } + + fun `test active permission replaces active question`() { + val item = panelWithPrompts() + model.setState(SessionState.AwaitingQuestion(question())) + model.setState(SessionState.AwaitingPermission(permission())) + + val qv = find(item)!! + val pv = find(item)!! + val comps = item.components.toList() + + assertFalse(qv.isVisible) + assertTrue(pv.isVisible) + assertSame(item.progress, comps.last()) + } + + fun `test idle hides active prompt and keeps progress footer last`() { + val item = panelWithPrompts() + model.setState(SessionState.AwaitingQuestion(question())) + model.setState(SessionState.Idle) + + val qv = find(item)!! + val pv = find(item)!! + + assertFalse(qv.isVisible) + assertFalse(pv.isVisible) + assertSame(item.progress, item.components.last()) + } + + fun `test cleared hides active prompt`() { + val item = panelWithPrompts() + model.setState(SessionState.AwaitingPermission(permission())) + model.clear() + + val pv = find(item)!! + + assertFalse(pv.isVisible) + assertSame(item.progress, item.components.last()) + } + // ------ helpers ------ + private fun panelWithPrompts(): SessionMessageListPanel { + val q = QuestionView( + reply = { _, _ -> }, + reject = { _ -> }, + ) + val p = PermissionView( + reply = { _, _ -> }, + ) + return SessionMessageListPanel(model, parent, q, p) + } + + private inline fun find(root: Container): T? = findCls(root, T::class.java) + + private fun findCls(root: Container, cls: Class): T? { + if (cls.isInstance(root)) return cls.cast(root) + for (child in root.components) { + if (cls.isInstance(child)) return cls.cast(child) + if (child is Container) { + val item = findCls(child, cls) + if (item != null) return item + } + } + return null + } + + private fun question(id: String = "q1") = Question( + id = id, + items = listOf( + QuestionItem( + question = "Proceed?", + header = "Confirm", + options = listOf(QuestionOption("Yes", "Continue")), + multiple = false, + custom = true, + ), + ), + ) + + private fun permission(id: String = "p1") = Permission( + id = id, + sessionId = "ses", + name = "edit", + patterns = listOf("*.kt"), + always = emptyList(), + meta = PermissionMeta(), + ) + private fun msg(id: String, role: String) = MessageDto( id = id, sessionID = "ses", role = role, time = MessageTimeDto(0.0), ) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PermissionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PermissionViewTest.kt new file mode 100644 index 00000000000..73dca0fc954 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PermissionViewTest.kt @@ -0,0 +1,74 @@ +package ai.kilocode.client.session.views + +import ai.kilocode.client.session.model.Permission +import ai.kilocode.client.session.model.PermissionMeta +import ai.kilocode.rpc.dto.PermissionReplyDto +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.awt.Container +import javax.swing.AbstractButton + +@Suppress("UnstableApiUsage") +class PermissionViewTest : BasePlatformTestCase() { + + private val replies = mutableListOf>() + private lateinit var view: PermissionView + + override fun setUp() { + super.setUp() + view = PermissionView( + reply = { id, dto -> replies.add(id to dto) }, + ) + } + + fun `test allow button uses bundle text and replies once`() { + view.show(permission()) + + buttons(view).first { it.text == "Allow" }.doClick() + + assertFalse(view.isVisible) + assertEquals(1, replies.size) + assertEquals("perm1", replies.single().first) + assertEquals("once", replies.single().second.reply) + } + + fun `test deny button uses bundle text and rejects`() { + view.show(permission()) + + buttons(view).first { it.text == "Deny" }.doClick() + + assertFalse(view.isVisible) + assertEquals(1, replies.size) + assertEquals("perm1", replies.single().first) + assertEquals("reject", replies.single().second.reply) + } + + fun `test blank patterns display star`() { + view.show( + Permission( + id = "perm2", + sessionId = "ses", + name = "edit", + patterns = emptyList(), + always = emptyList(), + meta = PermissionMeta(), + ) + ) + + assertTrue(view.isVisible) + } + + private fun permission() = Permission( + id = "perm1", + sessionId = "ses_test", + name = "edit", + patterns = listOf("*.kt"), + always = emptyList(), + meta = PermissionMeta(), + message = "Review file changes", + ) + + private fun buttons(root: Container): List = root.components.flatMap { comp -> + val item = if (comp is AbstractButton) listOf(comp) else emptyList() + if (comp is Container) item + buttons(comp) else item + } +} 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 new file mode 100644 index 00000000000..776549be8b6 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt @@ -0,0 +1,176 @@ +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.rpc.dto.QuestionReplyDto +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.awt.Container +import javax.swing.JButton + +@Suppress("UnstableApiUsage") +class QuestionViewTest : BasePlatformTestCase() { + + private val replies = mutableListOf>() + private val rejects = mutableListOf() + private lateinit var view: QuestionView + + override fun setUp() { + super.setUp() + view = QuestionView( + reply = { id, dto -> replies.add(id to dto) }, + reject = { id -> rejects.add(id) }, + ) + } + + fun `test empty question hides view and clears stale request id`() { + view.show( + Question( + id = "req_old", + items = listOf( + QuestionItem( + question = "Pick one", + header = "Header", + options = listOf(QuestionOption("Yes", "desc")), + multiple = false, + custom = true, + ) + ), + ) + ) + assertTrue(view.isVisible) + + view.show(Question(id = "req_new", items = emptyList())) + + assertFalse(view.isVisible) + assertTrue(replies.isEmpty()) + assertTrue(rejects.isEmpty()) + } + + fun `test dismiss button uses bundle text and rejects question`() { + view.show( + Question( + id = "req_1", + items = listOf( + QuestionItem( + question = "Pick one", + header = "Header", + options = listOf(QuestionOption("Yes", "desc")), + multiple = false, + custom = true, + ) + ), + ) + ) + + buttons(view).first { it.text == "Dismiss" }.doClick() + + assertFalse(view.isVisible) + assertEquals("req_1", rejects.single()) + assertTrue(replies.isEmpty()) + } + + fun `test single question submit sends answer`() { + view.show( + Question( + id = "req_2", + items = listOf( + QuestionItem( + question = "Choose approach", + header = "Approach", + options = listOf( + QuestionOption("Minimal", "Keep it simple"), + QuestionOption("Refactor", "Full refactor"), + ), + multiple = false, + custom = false, + ) + ), + ) + ) + + buttons(view).first { it.text == "Minimal" }.doClick() + buttons(view).first { it.text == "Submit" }.doClick() + + assertFalse(view.isVisible) + assertEquals(1, replies.size) + assertEquals("req_2", replies.single().first) + assertEquals(listOf(listOf("Minimal")), replies.single().second.answers) + } + + 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, + ), + ), + ) + ) + + buttons(view).first { it.text == "Minimal" }.doClick() + buttons(view).first { it.text == "Unit" }.doClick() + buttons(view).first { it.text == "Submit" }.doClick() + + assertFalse(view.isVisible) + assertEquals(1, replies.size) + assertEquals("q_strategy", replies.single().first) + assertEquals(listOf(listOf("Minimal"), listOf("Unit")), replies.single().second.answers) + } + + fun `test multiple selection item toggles options`() { + view.show( + Question( + id = "req_3", + items = listOf( + QuestionItem( + question = "Select features", + header = "Features", + options = listOf( + QuestionOption("A", "Feature A"), + QuestionOption("B", "Feature B"), + QuestionOption("C", "Feature C"), + ), + multiple = true, + custom = false, + ) + ), + ) + ) + + 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() + + assertFalse(view.isVisible) + assertEquals(1, replies.size) + assertEquals("req_3", replies.single().first) + 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 + } +}