From ae31cf218ea90a1d8a2168ccc2e4cea52d3aeefe Mon Sep 17 00:00:00 2001 From: kirillk Date: Sun, 17 May 2026 15:29:11 -0400 Subject: [PATCH] feat(jetbrains): render question/permission prompts in scrollable transcript Move active question and permission forms from fixed bottom panels above the prompt into the scrollable chat transcript. Users can now scroll through prior messages while a form is active and scroll back down to it. - Add QuestionView and PermissionView in session/views/ with callback-based wiring (no direct RPC access from views) - Update SessionMessageListPanel to own optional active views, syncing them from SessionModelEvent.StateChanged and anchoring before ProgressPanel - Update SessionUi to create views with controller lambdas, pass them into SessionMessageListPanel, and remove them from the SOUTH stack - Remove unused Dock.neutral() and Dock.warning() from SessionUiStyle - Replace QuestionPanelTest/PermissionPanelTest with faster callback-based QuestionViewTest/PermissionViewTest under session/views/ --- .changeset/jetbrains-scrollable-prompts.md | 5 + .../ai/kilocode/client/session/SessionUi.kt | 42 +-- .../client/session/ui/PermissionPanel.kt | 83 ------ .../session/ui/SessionMessageListPanel.kt | 56 +++- .../client/session/ui/style/SessionUiStyle.kt | 12 +- .../client/session/views/PermissionView.kt | 100 ++++++++ .../QuestionView.kt} | 81 +++--- .../client/session/SessionUiLayoutTest.kt | 84 ++++-- .../client/session/ui/PermissionPanelTest.kt | 115 --------- .../client/session/ui/QuestionPanelTest.kt | 240 ------------------ .../session/ui/SessionMessageListPanelTest.kt | 111 ++++++++ .../session/views/PermissionViewTest.kt | 74 ++++++ .../client/session/views/QuestionViewTest.kt | 176 +++++++++++++ 13 files changed, 644 insertions(+), 535 deletions(-) create mode 100644 .changeset/jetbrains-scrollable-prompts.md delete mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/PermissionPanel.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PermissionView.kt rename packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/{ui/QuestionPanel.kt => views/QuestionView.kt} (58%) delete mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PermissionPanelTest.kt delete mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/QuestionPanelTest.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PermissionViewTest.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt 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 + } +}