mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-19 01:51:21 +08:00
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/
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Render active question and permission prompts inside the scrollable JetBrains chat transcript.
|
||||
+13
-29
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
-83
@@ -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 │
|
||||
* │ <optional message> │
|
||||
* │ [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()
|
||||
}
|
||||
}
|
||||
+53
-3
@@ -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()
|
||||
}
|
||||
|
||||
+1
-11
@@ -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()),
|
||||
)!!
|
||||
}
|
||||
|
||||
+100
@@ -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()
|
||||
}
|
||||
}
|
||||
+51
-30
@@ -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<String>() }
|
||||
|
||||
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<List<String>>) {
|
||||
private fun doReply(answers: List<List<String>>) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
+60
-24
@@ -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<SessionRootPanel>(ui)
|
||||
val question = find<QuestionPanel>(ui)
|
||||
val permission = find<PermissionPanel>(ui)
|
||||
val connection = find<ConnectionPanel>(ui)
|
||||
val prompt = find<PromptPanel>(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<SessionMessageListPanel>(ui)
|
||||
val qv = find<QuestionView>(ui)
|
||||
val pv = find<PermissionView>(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<ConnectionPanel>(ui)
|
||||
val question = find<QuestionPanel>(ui)
|
||||
val prompt = find<PromptPanel>(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<ConnectionPanel>(ui)
|
||||
val prompt = find<PromptPanel>(ui)
|
||||
val top = connection.y
|
||||
|
||||
controller().model.setState(questionStateChanged())
|
||||
layout()
|
||||
|
||||
assertTrue(question.isVisible)
|
||||
assertTrue(question.y < connection.y)
|
||||
assertTrue(top < connection.y)
|
||||
assertTrue(find<QuestionView>(ui).isVisible)
|
||||
assertSame(find<SessionMessageListPanel>(ui), find<QuestionView>(ui).parent)
|
||||
assertEquals(top, connection.y)
|
||||
assertTrue(connection.y + connection.height <= prompt.y)
|
||||
assertSame(find<SessionMessageListPanel>(ui), scrollView())
|
||||
}
|
||||
|
||||
fun `test connection panel moves after visible permission panel`() {
|
||||
val connection = find<ConnectionPanel>(ui)
|
||||
val permission = find<PermissionPanel>(ui)
|
||||
val prompt = find<PromptPanel>(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<ConnectionPanel>(ui)
|
||||
val prompt = find<PromptPanel>(ui)
|
||||
val top = connection.y
|
||||
|
||||
controller().model.setState(permissionStateChanged())
|
||||
layout()
|
||||
|
||||
assertTrue(permission.isVisible)
|
||||
assertTrue(permission.y < connection.y)
|
||||
assertTrue(top < connection.y)
|
||||
assertTrue(find<PermissionView>(ui).isVisible)
|
||||
assertSame(find<SessionMessageListPanel>(ui), find<PermissionView>(ui).parent)
|
||||
assertEquals(top, connection.y)
|
||||
assertTrue(connection.y + connection.height <= prompt.y)
|
||||
assertSame(find<SessionMessageListPanel>(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<SessionMessageListPanel>(ui), scrollView())
|
||||
assertTrue(find<QuestionView>(ui).isVisible)
|
||||
assertSame(find<SessionMessageListPanel>(ui), find<QuestionView>(ui).parent)
|
||||
assertTrue(find<QuestionView>(ui).parent !== find<PromptPanel>(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<SessionMessageListPanel>(ui), scrollView())
|
||||
assertTrue(find<PermissionView>(ui).isVisible)
|
||||
assertSame(find<SessionMessageListPanel>(ui), find<PermissionView>(ui).parent)
|
||||
assertTrue(find<PermissionView>(ui).parent !== find<PromptPanel>(ui).parent)
|
||||
}
|
||||
|
||||
fun `test empty and message bodies share the same scroll pane`() {
|
||||
|
||||
-115
@@ -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<AbstractButton> = 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-240
@@ -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<Triple<String, String, ai.kilocode.rpc.dto.QuestionReplyDto>>) {
|
||||
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<JButton> = 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+111
@@ -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<QuestionView>(item)!!
|
||||
val pv = find<PermissionView>(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<QuestionView>(item)!!
|
||||
val pv = find<PermissionView>(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<QuestionView>(item)!!
|
||||
val pv = find<PermissionView>(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<PermissionView>(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 <reified T> find(root: Container): T? = findCls(root, T::class.java)
|
||||
|
||||
private fun <T> findCls(root: Container, cls: Class<T>): 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),
|
||||
)
|
||||
|
||||
+74
@@ -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<Pair<String, PermissionReplyDto>>()
|
||||
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<AbstractButton> = root.components.flatMap { comp ->
|
||||
val item = if (comp is AbstractButton) listOf(comp) else emptyList()
|
||||
if (comp is Container) item + buttons(comp) else item
|
||||
}
|
||||
}
|
||||
+176
@@ -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<Pair<String, QuestionReplyDto>>()
|
||||
private val rejects = mutableListOf<String>()
|
||||
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<JButton> = root.components.flatMap { comp ->
|
||||
val item = if (comp is JButton) listOf(comp) else emptyList()
|
||||
if (comp is Container) item + buttons(comp) else item
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user