mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
feat(jetbrains): add review step and answered-question card for question tool parity
Multi-question prompts now show a Review pseudo-step before submitting, listing all questions and selected answers (matching VS Code behavior). Completed question tool parts render as a structured 'Questions / N answered' card instead of raw output text.
This commit is contained in:
+16
@@ -85,6 +85,10 @@ class MessageView(
|
||||
}
|
||||
val existing = parts[content.id]
|
||||
if (existing != null) {
|
||||
if (ViewFactory.shouldReplace(existing, content)) {
|
||||
replacePart(content, existing)
|
||||
return
|
||||
}
|
||||
existing.update(content)
|
||||
refresh()
|
||||
return
|
||||
@@ -97,6 +101,18 @@ class MessageView(
|
||||
refresh()
|
||||
}
|
||||
|
||||
private fun replacePart(content: Content, existing: PartView) {
|
||||
val at = components.indexOfFirst { it === existing }.takeIf { it >= 0 } ?: componentCount
|
||||
parts.remove(content.id)
|
||||
remove(existing)
|
||||
val view = ViewFactory.create(content)
|
||||
view.applyStyle(style)
|
||||
parts[content.id] = view
|
||||
add(view, at)
|
||||
syncBorder()
|
||||
refresh()
|
||||
}
|
||||
|
||||
/** Remove the renderer for [contentId] if present. */
|
||||
fun removePart(contentId: String) {
|
||||
val view = parts.remove(contentId) ?: return
|
||||
|
||||
+301
@@ -0,0 +1,301 @@
|
||||
package ai.kilocode.client.session.views
|
||||
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.client.session.model.Content
|
||||
import ai.kilocode.client.session.model.Tool
|
||||
import ai.kilocode.client.session.model.ToolExecState
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyle
|
||||
import ai.kilocode.client.session.ui.style.SessionUiStyle
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import com.intellij.icons.AllIcons
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.ui.components.JBTextArea
|
||||
import com.intellij.util.ui.JBUI
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Color
|
||||
import java.awt.Component
|
||||
import java.awt.Cursor
|
||||
import java.awt.Dimension
|
||||
import java.awt.Font
|
||||
import java.awt.event.MouseAdapter
|
||||
import java.awt.event.MouseEvent
|
||||
import javax.swing.Box
|
||||
import javax.swing.BoxLayout
|
||||
import javax.swing.JPanel
|
||||
import javax.swing.SwingUtilities
|
||||
|
||||
private data class QuestionResult(
|
||||
val questions: List<String>,
|
||||
val answers: List<List<String>>,
|
||||
)
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private fun parseQuestions(raw: String): List<String>? {
|
||||
val arr = runCatching { json.parseToJsonElement(raw).jsonArray }.getOrNull() ?: return null
|
||||
val list = arr.mapNotNull { elem ->
|
||||
elem.jsonObject["question"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
return list.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
private fun parseAnswers(raw: String?): List<List<String>> {
|
||||
if (raw.isNullOrBlank()) return emptyList()
|
||||
val arr = runCatching { json.parseToJsonElement(raw).jsonArray }.getOrNull() ?: return emptyList()
|
||||
return arr.map { elem ->
|
||||
runCatching {
|
||||
elem.jsonArray.mapNotNull { it.jsonPrimitive.contentOrNull?.takeIf(String::isNotBlank) }
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
private fun parse(tool: Tool): QuestionResult? {
|
||||
if (tool.name != "question") return null
|
||||
if (tool.state != ToolExecState.COMPLETED) return null
|
||||
val raw = tool.input["questions"] ?: return null
|
||||
val questions = parseQuestions(raw) ?: return null
|
||||
val answers = parseAnswers(tool.metadata["answers"])
|
||||
return QuestionResult(questions, answers)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders completed `question` tool parts as a structured answered-question card.
|
||||
*
|
||||
* Shows each question with its selected answer(s). Hides raw output text.
|
||||
* Falls back to [ToolView] when structured data cannot be parsed.
|
||||
*/
|
||||
class QuestionResultView(tool: Tool) : PartView() {
|
||||
|
||||
override val contentId: String = tool.id
|
||||
|
||||
private var result = parse(tool) ?: QuestionResult(emptyList(), emptyList())
|
||||
private var style = SessionEditorStyle.current()
|
||||
|
||||
private val root = JPanel(BorderLayout()).apply {
|
||||
isOpaque = true
|
||||
background = SessionUiStyle.View.surface()
|
||||
border = SessionUiStyle.View.card()
|
||||
}
|
||||
private val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0)).apply {
|
||||
isOpaque = true
|
||||
background = SessionUiStyle.View.header()
|
||||
border = JBUI.Borders.empty(
|
||||
JBUI.scale(SessionUiStyle.View.CARD_VERTICAL_PADDING),
|
||||
JBUI.scale(SessionUiStyle.View.CARD_HORIZONTAL_PADDING),
|
||||
)
|
||||
}
|
||||
private val glyph = JBLabel(AllIcons.General.Balloon)
|
||||
private val title = JBLabel()
|
||||
private val sub = JBLabel().apply { foreground = UiStyle.Colors.weak() }
|
||||
private val arrow = JBLabel()
|
||||
private val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0)).apply {
|
||||
isOpaque = false
|
||||
}
|
||||
private val body = JPanel().apply {
|
||||
isOpaque = true
|
||||
background = SessionUiStyle.View.surface()
|
||||
layout = BoxLayout(this, BoxLayout.Y_AXIS)
|
||||
border = JBUI.Borders.empty(
|
||||
JBUI.scale(SessionUiStyle.View.CARD_VERTICAL_PADDING),
|
||||
JBUI.scale(SessionUiStyle.View.CARD_HORIZONTAL_PADDING),
|
||||
)
|
||||
}
|
||||
|
||||
// Text components tracked for font updates
|
||||
private val textComponents = mutableListOf<JBTextArea>()
|
||||
|
||||
private val click = object : MouseAdapter() {
|
||||
override fun mouseClicked(e: MouseEvent) { toggle() }
|
||||
}
|
||||
|
||||
private val mouse = object : MouseAdapter() {
|
||||
override fun mouseEntered(e: MouseEvent) { setHover(true) }
|
||||
override fun mouseExited(e: MouseEvent) {
|
||||
if (inside(e)) return
|
||||
setHover(false)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
layout = BorderLayout()
|
||||
isOpaque = false
|
||||
|
||||
center.add(title, BorderLayout.WEST)
|
||||
center.add(sub, BorderLayout.CENTER)
|
||||
header.add(glyph, BorderLayout.WEST)
|
||||
header.add(center, BorderLayout.CENTER)
|
||||
header.add(arrow, BorderLayout.EAST)
|
||||
root.add(header, BorderLayout.NORTH)
|
||||
|
||||
listOf(header, glyph, title, sub, arrow, center).forEach {
|
||||
it.addMouseListener(click)
|
||||
it.addMouseListener(mouse)
|
||||
it.cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
|
||||
}
|
||||
header.cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
|
||||
|
||||
applyStyle(SessionEditorStyle.current())
|
||||
add(root, BorderLayout.CENTER)
|
||||
syncLabels()
|
||||
rebuildBody()
|
||||
// Default expanded: add body
|
||||
root.add(body, BorderLayout.CENTER)
|
||||
syncArrow()
|
||||
}
|
||||
|
||||
override fun update(content: Content) {
|
||||
if (content !is Tool) return
|
||||
val parsed = parse(content)
|
||||
result = parsed ?: QuestionResult(emptyList(), emptyList())
|
||||
syncLabels()
|
||||
rebuildBody()
|
||||
refresh()
|
||||
}
|
||||
|
||||
override fun applyStyle(style: SessionEditorStyle) {
|
||||
this.style = style
|
||||
setFont(title, style.boldEditorFont)
|
||||
setFont(sub, style.smallEditorFont)
|
||||
for (ta in textComponents) {
|
||||
val bold = ta.font?.isBold == true
|
||||
ta.font = if (bold) style.boldEditorFont else style.transcriptFont
|
||||
}
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun toggle() {
|
||||
val expanded = isExpanded()
|
||||
if (expanded) root.remove(body) else root.add(body, BorderLayout.CENTER)
|
||||
syncArrow()
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun isExpanded(): Boolean = body.parent === root
|
||||
|
||||
/** Label text for test assertions — title + sub joined. */
|
||||
fun labelText(): String = listOf(title.text, sub.text).filter { it.isNotBlank() }.joinToString(" ")
|
||||
|
||||
/** Body text for test assertions — all text areas joined. */
|
||||
fun bodyText(): String = textComponents.joinToString("\n") { it.text }
|
||||
|
||||
override fun dumpLabel(): String = "QuestionResultView#$contentId(${labelText()})"
|
||||
|
||||
companion object {
|
||||
fun canRender(tool: Tool): Boolean = parse(tool) != null
|
||||
}
|
||||
|
||||
// ── Private helpers ───────────────────────────────────────────────────────
|
||||
|
||||
private fun syncLabels() {
|
||||
title.text = KiloBundle.message("session.question.result.title")
|
||||
val count = result.answers.count { it.isNotEmpty() }
|
||||
sub.text = KiloBundle.message("session.question.result.answered", count)
|
||||
}
|
||||
|
||||
private fun rebuildBody() {
|
||||
body.removeAll()
|
||||
textComponents.clear()
|
||||
|
||||
for ((i, q) in result.questions.withIndex()) {
|
||||
val row = JPanel().apply {
|
||||
isOpaque = false
|
||||
layout = BoxLayout(this, BoxLayout.Y_AXIS)
|
||||
alignmentX = Component.LEFT_ALIGNMENT
|
||||
}
|
||||
if (i > 0) {
|
||||
row.border = JBUI.Borders.emptyTop(UiStyle.Gap.lg())
|
||||
}
|
||||
|
||||
val qText = makeText(q, UiStyle.Colors.weak(), false)
|
||||
qText.alignmentX = Component.LEFT_ALIGNMENT
|
||||
row.add(qText)
|
||||
|
||||
val joined = result.answers.getOrNull(i)?.joinToString(", ").orEmpty()
|
||||
val aText = makeText(
|
||||
joined.ifBlank { KiloBundle.message("session.question.review.notAnswered") },
|
||||
UiStyle.Colors.fg(),
|
||||
true,
|
||||
)
|
||||
aText.alignmentX = Component.LEFT_ALIGNMENT
|
||||
row.add(aText)
|
||||
|
||||
body.add(row)
|
||||
}
|
||||
}
|
||||
|
||||
private fun makeText(value: String, color: Color, bold: Boolean): JBTextArea {
|
||||
val ta = object : JBTextArea(value) {
|
||||
override fun getPreferredSize(): Dimension {
|
||||
val width = space()
|
||||
if (width <= 0) return super.getPreferredSize()
|
||||
val old = size
|
||||
setSize(width, Int.MAX_VALUE)
|
||||
val size = super.getPreferredSize()
|
||||
setSize(old)
|
||||
return Dimension(width, size.height)
|
||||
}
|
||||
|
||||
override fun getMaximumSize(): Dimension {
|
||||
val size = preferredSize
|
||||
return Dimension(Int.MAX_VALUE, size.height)
|
||||
}
|
||||
|
||||
private fun space(): Int {
|
||||
var node = parent
|
||||
while (node != null) {
|
||||
if (node.width > 0) {
|
||||
val ins = node.insets
|
||||
return (node.width - ins.left - ins.right).coerceAtLeast(0)
|
||||
}
|
||||
node = node.parent
|
||||
}
|
||||
return width
|
||||
}
|
||||
}.apply {
|
||||
isEditable = false
|
||||
isOpaque = false
|
||||
isFocusable = false
|
||||
caret.isVisible = false
|
||||
caret.isSelectionVisible = false
|
||||
lineWrap = true
|
||||
wrapStyleWord = true
|
||||
foreground = color
|
||||
border = JBUI.Borders.empty()
|
||||
font = if (bold) style.boldEditorFont else style.transcriptFont
|
||||
}
|
||||
textComponents.add(ta)
|
||||
return ta
|
||||
}
|
||||
|
||||
private fun syncArrow() {
|
||||
arrow.icon = if (isExpanded()) AllIcons.General.ArrowDown else AllIcons.General.ArrowRight
|
||||
}
|
||||
|
||||
private fun setHover(value: Boolean) {
|
||||
val color = if (value) SessionUiStyle.View.headerHover() else SessionUiStyle.View.header()
|
||||
if (header.background?.rgb == color.rgb) return
|
||||
header.background = color
|
||||
header.repaint()
|
||||
}
|
||||
|
||||
private fun inside(e: MouseEvent): Boolean {
|
||||
val point = SwingUtilities.convertPoint(e.component, e.point, header)
|
||||
return header.contains(point)
|
||||
}
|
||||
|
||||
private fun setFont(label: JBLabel, font: Font): Boolean {
|
||||
if (label.font == font) return false
|
||||
label.font = font
|
||||
return true
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
revalidate()
|
||||
repaint()
|
||||
}
|
||||
}
|
||||
+140
-20
@@ -38,6 +38,10 @@ import javax.swing.JPanel
|
||||
* Shows one [QuestionItem] at a time as a carousel with back/forward navigation.
|
||||
* Single-select items render as radio-button rows; multi-select as checkbox rows.
|
||||
* Each option shows a bold label and a regular description.
|
||||
*
|
||||
* For multi-question prompts (or a single multi-select item), a review pseudo-step
|
||||
* is inserted after the last real question. The review step lists all questions
|
||||
* and their selected answers before the final Submit.
|
||||
*/
|
||||
class QuestionView(
|
||||
private val reply: (String, QuestionReplyDto) -> Unit,
|
||||
@@ -94,9 +98,7 @@ class QuestionView(
|
||||
|
||||
private fun render() {
|
||||
val q = question ?: return
|
||||
val item = q.items[idx]
|
||||
val total = q.items.size
|
||||
val set = selections[idx]
|
||||
|
||||
removeAll()
|
||||
|
||||
@@ -104,7 +106,9 @@ class QuestionView(
|
||||
card.isOpaque = true
|
||||
card.background = SessionUiStyle.View.surface()
|
||||
card.border = SessionUiStyle.View.card()
|
||||
card.add(buildContent(item, total, set), BorderLayout.CENTER)
|
||||
|
||||
val node = if (review(q)) buildReview(q) else buildContent(q.items[idx], total, selections[idx])
|
||||
card.add(node, BorderLayout.CENTER)
|
||||
add(card, BorderLayout.CENTER)
|
||||
}
|
||||
|
||||
@@ -116,7 +120,26 @@ class QuestionView(
|
||||
|
||||
val head = header(total)
|
||||
val body = body(item, set)
|
||||
val foot = footer(item, set)
|
||||
val foot = footer()
|
||||
head.alignmentX = Component.LEFT_ALIGNMENT
|
||||
body.alignmentX = Component.LEFT_ALIGNMENT
|
||||
foot.alignmentX = Component.LEFT_ALIGNMENT
|
||||
root.add(head)
|
||||
root.add(body)
|
||||
root.add(foot)
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
private fun buildReview(q: Question): JPanel {
|
||||
val root = JPanel()
|
||||
root.isOpaque = false
|
||||
root.layout = BoxLayout(root, BoxLayout.Y_AXIS)
|
||||
root.border = JBUI.Borders.empty(UiStyle.Gap.lg(), UiStyle.Gap.pad(), UiStyle.Gap.lg(), UiStyle.Gap.pad())
|
||||
|
||||
val head = header(q.items.size)
|
||||
val body = reviewBody(q)
|
||||
val foot = footer()
|
||||
head.alignmentX = Component.LEFT_ALIGNMENT
|
||||
body.alignmentX = Component.LEFT_ALIGNMENT
|
||||
foot.alignmentX = Component.LEFT_ALIGNMENT
|
||||
@@ -134,7 +157,8 @@ class QuestionView(
|
||||
row.isOpaque = false
|
||||
row.border = JBUI.Borders.emptyBottom(UiStyle.Gap.lg())
|
||||
|
||||
val summary = JBLabel(KiloBundle.message("session.question.summary", idx + 1, total))
|
||||
val shown = minOf(idx + 1, total)
|
||||
val summary = JBLabel(KiloBundle.message("session.question.summary", shown, total))
|
||||
summary.foreground = UiStyle.Colors.weak()
|
||||
row.add(summary, BorderLayout.WEST)
|
||||
|
||||
@@ -165,7 +189,7 @@ class QuestionView(
|
||||
disabledIcon = IconLoader.getDisabledIcon(ico)
|
||||
toolTipText = KiloBundle.message("session.question.next")
|
||||
val q = question
|
||||
isEnabled = q != null && idx < q.items.size - 1 && selections[idx].isNotEmpty()
|
||||
isEnabled = q != null && idx < q.items.size && selections.getOrNull(idx)?.isNotEmpty() == true
|
||||
addActionListener { goForward() }
|
||||
}
|
||||
|
||||
@@ -199,6 +223,48 @@ class QuestionView(
|
||||
return panel
|
||||
}
|
||||
|
||||
// ── Review body ───────────────────────────────────────────────────────────
|
||||
|
||||
private fun reviewBody(q: Question): JPanel {
|
||||
val panel = JPanel()
|
||||
panel.isOpaque = false
|
||||
panel.layout = BoxLayout(panel, BoxLayout.Y_AXIS)
|
||||
|
||||
val title = text(KiloBundle.message("session.question.review.title"), UiStyle.Colors.fg(), true)
|
||||
title.border = JBUI.Borders.emptyBottom(UiStyle.Gap.lg())
|
||||
title.alignmentX = Component.LEFT_ALIGNMENT
|
||||
panel.add(title)
|
||||
|
||||
for ((i, item) in q.items.withIndex()) {
|
||||
val row = reviewRow(item, i)
|
||||
row.alignmentX = Component.LEFT_ALIGNMENT
|
||||
panel.add(row)
|
||||
}
|
||||
return panel
|
||||
}
|
||||
|
||||
private fun reviewRow(item: QuestionItem, i: Int): JPanel {
|
||||
val row = JPanel()
|
||||
row.isOpaque = false
|
||||
row.layout = BoxLayout(row, BoxLayout.Y_AXIS)
|
||||
row.border = JBUI.Borders.emptyBottom(UiStyle.Gap.lg())
|
||||
|
||||
val question = text(item.question, UiStyle.Colors.weak())
|
||||
question.alignmentX = Component.LEFT_ALIGNMENT
|
||||
row.add(question)
|
||||
|
||||
val joined = selections.getOrNull(i)?.joinToString(", ").orEmpty()
|
||||
val answer = text(
|
||||
joined.ifBlank { KiloBundle.message("session.question.review.notAnswered") },
|
||||
UiStyle.Colors.fg(),
|
||||
true,
|
||||
)
|
||||
answer.alignmentX = Component.LEFT_ALIGNMENT
|
||||
row.add(answer)
|
||||
|
||||
return row
|
||||
}
|
||||
|
||||
private fun optionList(item: QuestionItem, set: MutableSet<String>): JPanel {
|
||||
val panel = JPanel()
|
||||
panel.isOpaque = false
|
||||
@@ -338,11 +404,10 @@ class QuestionView(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Footer: Dismiss + Next/Submit ─────────────────────────────────────────
|
||||
// ── Footer: Dismiss + Back/Next/Review/Submit ─────────────────────────────
|
||||
|
||||
private fun footer(item: QuestionItem, set: MutableSet<String>): JPanel {
|
||||
private fun footer(): JPanel {
|
||||
val q = question ?: return JPanel()
|
||||
val last = idx == q.items.size - 1
|
||||
|
||||
val row = JPanel(BorderLayout())
|
||||
row.isOpaque = false
|
||||
@@ -352,20 +417,61 @@ class QuestionView(
|
||||
dismiss.addActionListener { doReject() }
|
||||
row.add(dismiss, BorderLayout.WEST)
|
||||
|
||||
val rightLabel = if (last) KiloBundle.message("session.question.submit")
|
||||
else KiloBundle.message("session.question.next")
|
||||
val right = JButton(rightLabel)
|
||||
right.putClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY, last)
|
||||
right.isEnabled = set.isNotEmpty()
|
||||
right.addActionListener {
|
||||
if (last) doReply()
|
||||
else goForward()
|
||||
if (review(q)) {
|
||||
// Review page: Back + Submit
|
||||
val right = JPanel()
|
||||
right.isOpaque = false
|
||||
right.layout = BoxLayout(right, BoxLayout.X_AXIS)
|
||||
|
||||
val back = JButton(KiloBundle.message("session.question.back"))
|
||||
back.addActionListener { goBack() }
|
||||
right.add(back)
|
||||
|
||||
right.add(javax.swing.Box.createHorizontalStrut(JBUI.scale(UiStyle.Gap.sm())))
|
||||
|
||||
val submit = JButton(KiloBundle.message("session.question.submit"))
|
||||
submit.putClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY, true)
|
||||
submit.addActionListener { doReply() }
|
||||
right.add(submit)
|
||||
|
||||
row.add(right, BorderLayout.EAST)
|
||||
} else {
|
||||
val label = when {
|
||||
direct(q) -> KiloBundle.message("session.question.submit")
|
||||
lastItem(q) -> KiloBundle.message("session.question.review")
|
||||
else -> KiloBundle.message("session.question.next")
|
||||
}
|
||||
val primary = direct(q) || lastItem(q)
|
||||
val right = JButton(label)
|
||||
right.putClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY, primary)
|
||||
right.isEnabled = selections.getOrNull(idx)?.isNotEmpty() == true
|
||||
right.addActionListener {
|
||||
when {
|
||||
direct(q) -> doReply()
|
||||
lastItem(q) -> goReview()
|
||||
else -> goForward()
|
||||
}
|
||||
}
|
||||
row.add(right, BorderLayout.EAST)
|
||||
}
|
||||
row.add(right, BorderLayout.EAST)
|
||||
|
||||
return row
|
||||
}
|
||||
|
||||
// ── State predicates ──────────────────────────────────────────────────────
|
||||
|
||||
/** True for a single non-multiple question — direct submit, no review step. */
|
||||
private fun single(q: Question): Boolean = q.items.size == 1 && !q.items[0].multiple
|
||||
|
||||
/** True when we are on the review pseudo-step. */
|
||||
private fun review(q: Question): Boolean = !single(q) && idx == q.items.size
|
||||
|
||||
/** True when we are on the last real question page. */
|
||||
private fun lastItem(q: Question): Boolean = idx == q.items.size - 1
|
||||
|
||||
/** True for a single non-multiple question — direct submit, no review step. */
|
||||
private fun direct(q: Question): Boolean = single(q)
|
||||
|
||||
// ── Navigation ────────────────────────────────────────────────────────────
|
||||
|
||||
private fun goBack() {
|
||||
@@ -378,14 +484,28 @@ class QuestionView(
|
||||
|
||||
private fun goForward() {
|
||||
val q = question ?: return
|
||||
if (idx >= q.items.size - 1) return
|
||||
if (selections[idx].isEmpty()) return
|
||||
if (idx >= q.items.size) return
|
||||
if (selections.getOrNull(idx)?.isEmpty() != false) return
|
||||
if (idx == q.items.size - 1 && !direct(q)) {
|
||||
goReview()
|
||||
return
|
||||
}
|
||||
idx++
|
||||
render()
|
||||
refresh()
|
||||
scroll()
|
||||
}
|
||||
|
||||
private fun goReview() {
|
||||
val q = question ?: return
|
||||
if (idx != q.items.size - 1) return
|
||||
if (selections[idx].isEmpty()) return
|
||||
idx = q.items.size
|
||||
render()
|
||||
refresh()
|
||||
scroll()
|
||||
}
|
||||
|
||||
private fun refreshSelection() {
|
||||
// Re-render is cheap and keeps state consistent; do a full render.
|
||||
render()
|
||||
|
||||
+13
-1
@@ -20,9 +20,21 @@ object ViewFactory {
|
||||
fun create(content: Content): PartView = when (content) {
|
||||
is Text -> TextView(content)
|
||||
is Reasoning -> ReasoningView(content)
|
||||
is Tool -> ToolView(content)
|
||||
is Tool -> if (QuestionResultView.canRender(content)) QuestionResultView(content) else ToolView(content)
|
||||
is Compaction -> CompactionView(content)
|
||||
is StepFinish -> error("step-finish is timeline-only")
|
||||
is Generic -> GenericView(content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when [view] must be replaced by a new renderer for [content].
|
||||
* This happens when a running question tool (rendered as [ToolView]) completes
|
||||
* with structured data and should become a [QuestionResultView].
|
||||
*/
|
||||
fun shouldReplace(view: PartView, content: Content): Boolean {
|
||||
if (content !is Tool) return false
|
||||
if (view is QuestionResultView) return !QuestionResultView.canRender(content)
|
||||
if (view is ToolView) return QuestionResultView.canRender(content)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,11 @@ session.question.back=Back
|
||||
session.question.summary={0} of {1} questions
|
||||
session.question.hint.single=Select one answer
|
||||
session.question.hint.multi=Select one or more answers
|
||||
session.question.review=Review
|
||||
session.question.review.title=Review your answers
|
||||
session.question.review.notAnswered=(not answered)
|
||||
session.question.result.title=Questions
|
||||
session.question.result.answered={0} answered
|
||||
|
||||
session.status.considering=Considering next steps…
|
||||
session.status.thinking=Thinking…
|
||||
|
||||
+35
-1
@@ -10,6 +10,7 @@ import ai.kilocode.client.session.model.SessionState
|
||||
import ai.kilocode.client.session.model.ToolCallRef
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyle
|
||||
import ai.kilocode.client.session.views.PermissionView
|
||||
import ai.kilocode.client.session.views.QuestionResultView
|
||||
import ai.kilocode.client.session.views.QuestionView
|
||||
import ai.kilocode.client.session.views.TextView
|
||||
import ai.kilocode.client.session.views.ToolView
|
||||
@@ -368,6 +369,7 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
|
||||
val item = panelWithPrompts()
|
||||
model.upsertMessage(msg("a1", "assistant"))
|
||||
// completed state — must NOT be suppressed even when callId matches
|
||||
// No structured input/metadata so it renders as ToolView
|
||||
model.updateContent("a1", toolPart("tp1", "a1", "question", "call1", state = "completed"))
|
||||
|
||||
model.setState(SessionState.AwaitingQuestion(question(tool = ToolCallRef("a1", "call1"))))
|
||||
@@ -377,6 +379,29 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
|
||||
assertTrue(mv.part("tp1") is ToolView)
|
||||
}
|
||||
|
||||
fun `test completed question update replaces generic tool view with question result view`() {
|
||||
val item = panelWithPrompts()
|
||||
model.upsertMessage(msg("a1", "assistant"))
|
||||
// Running question tool — no structured data yet, renders as ToolView
|
||||
model.updateContent("a1", toolPart("tp1", "a1", "question", "call1", state = "running"))
|
||||
|
||||
val mv = item.findMessage("a1")!!
|
||||
assertTrue("Running question tool should be ToolView", mv.part("tp1") is ToolView)
|
||||
|
||||
// Complete with structured data — should replace ToolView with QuestionResultView
|
||||
model.updateContent(
|
||||
"a1",
|
||||
toolPart(
|
||||
"tp1", "a1", "question", "call1", state = "completed",
|
||||
input = mapOf("questions" to """[{"question":"Which strategy?"},{"question":"Which checks?"}]"""),
|
||||
metadata = mapOf("answers" to """[["Comprehensive"],["Build"]]"""),
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue("Completed question with data should be QuestionResultView", mv.part("tp1") is QuestionResultView)
|
||||
assertEquals(listOf("tp1"), mv.partIds())
|
||||
}
|
||||
|
||||
// ------ helpers ------
|
||||
|
||||
private fun panelWithPrompts(): SessionMessageListPanel {
|
||||
@@ -435,7 +460,16 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
|
||||
id = id, sessionID = "ses", messageID = mid, type = type, text = text,
|
||||
)
|
||||
|
||||
private fun toolPart(id: String, mid: String, tool: String, callId: String, state: String = "running") = PartDto(
|
||||
private fun toolPart(
|
||||
id: String,
|
||||
mid: String,
|
||||
tool: String,
|
||||
callId: String,
|
||||
state: String = "running",
|
||||
input: Map<String, String> = emptyMap(),
|
||||
metadata: Map<String, String> = emptyMap(),
|
||||
) = PartDto(
|
||||
id = id, sessionID = "ses", messageID = mid, type = "tool", tool = tool, callID = callId, state = state,
|
||||
input = input, metadata = metadata,
|
||||
)
|
||||
}
|
||||
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
package ai.kilocode.client.session.views
|
||||
|
||||
import ai.kilocode.client.session.model.Tool
|
||||
import ai.kilocode.client.session.model.ToolExecState
|
||||
import ai.kilocode.client.session.model.toolKind
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyle
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
|
||||
@Suppress("UnstableApiUsage")
|
||||
class QuestionResultViewTest : BasePlatformTestCase() {
|
||||
|
||||
// ------ canRender ------
|
||||
|
||||
fun `test completed question tool is renderable`() {
|
||||
val tool = completedTool(
|
||||
input = mapOf("questions" to """[{"question":"Which strategy?"},{"question":"Which checks?"}]"""),
|
||||
metadata = mapOf("answers" to """[["Comprehensive"],["Build"]]"""),
|
||||
)
|
||||
assertTrue(QuestionResultView.canRender(tool))
|
||||
}
|
||||
|
||||
fun `test invalid question data is not renderable`() {
|
||||
val tool = completedTool(
|
||||
input = emptyMap(),
|
||||
metadata = emptyMap(),
|
||||
)
|
||||
assertFalse(QuestionResultView.canRender(tool))
|
||||
}
|
||||
|
||||
fun `test non-completed question tool is not renderable`() {
|
||||
val tool = runningTool("question")
|
||||
assertFalse(QuestionResultView.canRender(tool))
|
||||
}
|
||||
|
||||
fun `test non-question tool with question json is not renderable`() {
|
||||
val tool = completedTool(
|
||||
name = "bash",
|
||||
input = mapOf("questions" to """[{"question":"Which strategy?"}]"""),
|
||||
metadata = emptyMap(),
|
||||
)
|
||||
assertFalse(QuestionResultView.canRender(tool))
|
||||
}
|
||||
|
||||
fun `test empty questions array is not renderable`() {
|
||||
val tool = completedTool(
|
||||
input = mapOf("questions" to "[]"),
|
||||
metadata = emptyMap(),
|
||||
)
|
||||
assertFalse(QuestionResultView.canRender(tool))
|
||||
}
|
||||
|
||||
fun `test invalid json in questions is not renderable`() {
|
||||
val tool = completedTool(
|
||||
input = mapOf("questions" to "not json"),
|
||||
metadata = emptyMap(),
|
||||
)
|
||||
assertFalse(QuestionResultView.canRender(tool))
|
||||
}
|
||||
|
||||
// ------ label text ------
|
||||
|
||||
fun `test completed question tool renders answer summary`() {
|
||||
val tool = completedTool(
|
||||
input = mapOf("questions" to """[{"question":"Which implementation strategy should we use?"},{"question":"Which validation checks should be run?"}]"""),
|
||||
metadata = mapOf("answers" to """[["Comprehensive"],["Build"]]"""),
|
||||
output = "User has answered your questions: raw output should not be rendered",
|
||||
)
|
||||
val view = QuestionResultView(tool)
|
||||
|
||||
assertTrue(view.labelText().contains("Questions"))
|
||||
assertTrue(view.labelText().contains("2 answered"))
|
||||
assertTrue(view.bodyText().contains("Which implementation strategy should we use?"))
|
||||
assertTrue(view.bodyText().contains("Which validation checks should be run?"))
|
||||
assertTrue(view.bodyText().contains("Comprehensive"))
|
||||
assertTrue(view.bodyText().contains("Build"))
|
||||
assertFalse(view.bodyText().contains("raw output should not be rendered"))
|
||||
}
|
||||
|
||||
fun `test missing answer renders not answered`() {
|
||||
val tool = completedTool(
|
||||
input = mapOf("questions" to """[{"question":"Q1"},{"question":"Q2"}]"""),
|
||||
metadata = mapOf("answers" to """[["Answer1"]]"""),
|
||||
)
|
||||
val view = QuestionResultView(tool)
|
||||
|
||||
assertTrue(view.bodyText().contains("Q1"))
|
||||
assertTrue(view.bodyText().contains("Answer1"))
|
||||
assertTrue(view.bodyText().contains("Q2"))
|
||||
assertTrue(view.bodyText().contains("(not answered)"))
|
||||
}
|
||||
|
||||
fun `test no answers metadata renders all not answered`() {
|
||||
val tool = completedTool(
|
||||
input = mapOf("questions" to """[{"question":"Q1"}]"""),
|
||||
metadata = emptyMap(),
|
||||
)
|
||||
val view = QuestionResultView(tool)
|
||||
|
||||
assertTrue(view.bodyText().contains("Q1"))
|
||||
assertTrue(view.bodyText().contains("(not answered)"))
|
||||
}
|
||||
|
||||
fun `test multi answer row joins with comma`() {
|
||||
val tool = completedTool(
|
||||
input = mapOf("questions" to """[{"question":"Select features"}]"""),
|
||||
metadata = mapOf("answers" to """[["Manual verification","Unit tests"]]"""),
|
||||
)
|
||||
val view = QuestionResultView(tool)
|
||||
|
||||
assertTrue(view.bodyText().contains("Manual verification, Unit tests"))
|
||||
}
|
||||
|
||||
fun `test label shows count of non-empty answers`() {
|
||||
val tool = completedTool(
|
||||
input = mapOf("questions" to """[{"question":"Q1"},{"question":"Q2"}]"""),
|
||||
metadata = mapOf("answers" to """[["A1"],[]]"""),
|
||||
)
|
||||
val view = QuestionResultView(tool)
|
||||
|
||||
// Only 1 non-empty answer
|
||||
assertTrue(view.labelText().contains("1 answered"))
|
||||
}
|
||||
|
||||
// ------ toggle expand/collapse ------
|
||||
|
||||
fun `test toggle collapses and expands body`() {
|
||||
val tool = completedTool(
|
||||
input = mapOf("questions" to """[{"question":"Q1"}]"""),
|
||||
metadata = mapOf("answers" to """[["A1"]]"""),
|
||||
)
|
||||
val view = QuestionResultView(tool)
|
||||
|
||||
assertTrue("Default state should be expanded", view.isExpanded())
|
||||
|
||||
view.toggle()
|
||||
assertFalse("Should be collapsed after toggle", view.isExpanded())
|
||||
|
||||
view.toggle()
|
||||
assertTrue("Should be expanded after second toggle", view.isExpanded())
|
||||
}
|
||||
|
||||
// ------ view factory routing ------
|
||||
|
||||
fun `test view factory uses question result view for completed parsable question tool`() {
|
||||
val tool = completedTool(
|
||||
input = mapOf("questions" to """[{"question":"Q1"}]"""),
|
||||
metadata = mapOf("answers" to """[["A1"]]"""),
|
||||
)
|
||||
val view = ViewFactory.create(tool)
|
||||
|
||||
assertTrue(view is QuestionResultView)
|
||||
}
|
||||
|
||||
fun `test view factory falls back to tool view for invalid question result`() {
|
||||
val tool = completedTool(
|
||||
input = emptyMap(),
|
||||
metadata = emptyMap(),
|
||||
)
|
||||
val view = ViewFactory.create(tool)
|
||||
|
||||
assertTrue(view is ToolView)
|
||||
}
|
||||
|
||||
fun `test view factory falls back to tool view for running question`() {
|
||||
val tool = runningTool("question")
|
||||
val view = ViewFactory.create(tool)
|
||||
|
||||
assertTrue(view is ToolView)
|
||||
}
|
||||
|
||||
// ------ dumpLabel ------
|
||||
|
||||
fun `test dumpLabel format`() {
|
||||
val tool = completedTool(
|
||||
input = mapOf("questions" to """[{"question":"Q1"}]"""),
|
||||
metadata = mapOf("answers" to """[["A1"]]"""),
|
||||
)
|
||||
val view = QuestionResultView(tool)
|
||||
|
||||
assertTrue(view.dumpLabel().startsWith("QuestionResultView#"))
|
||||
assertTrue(view.dumpLabel().contains("Questions"))
|
||||
}
|
||||
|
||||
// ------ applyStyle ------
|
||||
|
||||
fun `test applyStyle updates fonts`() {
|
||||
val tool = completedTool(
|
||||
input = mapOf("questions" to """[{"question":"Q1"}]"""),
|
||||
metadata = mapOf("answers" to """[["A1"]]"""),
|
||||
)
|
||||
val view = QuestionResultView(tool)
|
||||
val style = SessionEditorStyle.create(family = "Courier New", size = 22)
|
||||
|
||||
view.applyStyle(style)
|
||||
|
||||
// Body text areas should use the new font family
|
||||
assertTrue(view.bodyText().isNotEmpty())
|
||||
}
|
||||
|
||||
// ------ update ------
|
||||
|
||||
fun `test update with completed structured tool refreshes content`() {
|
||||
val initial = completedTool(
|
||||
input = mapOf("questions" to """[{"question":"Initial Q"}]"""),
|
||||
metadata = mapOf("answers" to """[["Initial A"]]"""),
|
||||
)
|
||||
val view = QuestionResultView(initial)
|
||||
|
||||
val updated = completedTool(
|
||||
id = initial.id,
|
||||
input = mapOf("questions" to """[{"question":"Updated Q"}]"""),
|
||||
metadata = mapOf("answers" to """[["Updated A"]]"""),
|
||||
)
|
||||
view.update(updated)
|
||||
|
||||
assertTrue(view.bodyText().contains("Updated Q"))
|
||||
assertTrue(view.bodyText().contains("Updated A"))
|
||||
assertFalse(view.bodyText().contains("Initial Q"))
|
||||
}
|
||||
|
||||
// ------ helpers ------
|
||||
|
||||
private fun completedTool(
|
||||
id: String = "tp1",
|
||||
name: String = "question",
|
||||
input: Map<String, String> = emptyMap(),
|
||||
metadata: Map<String, String> = emptyMap(),
|
||||
output: String? = null,
|
||||
): Tool = Tool(id, name, toolKind(name)).apply {
|
||||
state = ToolExecState.COMPLETED
|
||||
this.input = input
|
||||
this.metadata = metadata
|
||||
this.output = output
|
||||
}
|
||||
|
||||
private fun runningTool(name: String, id: String = "tp1"): Tool =
|
||||
Tool(id, name, toolKind(name)).apply { state = ToolExecState.RUNNING }
|
||||
}
|
||||
+98
-5
@@ -177,8 +177,18 @@ class QuestionViewTest : BasePlatformTestCase() {
|
||||
assertLabelsDoNotContain(view, "Choose approach")
|
||||
assertLabelsContain(view, "2 of 2 questions")
|
||||
|
||||
// Select answer on second question, submit
|
||||
// Select answer on second question, click Review
|
||||
option<JBRadioButton>(view, "Unit").doClick()
|
||||
button(view, "Review").doClick()
|
||||
|
||||
// Review page shown
|
||||
assertLabelsContain(view, "Review your answers")
|
||||
assertLabelsContain(view, "Choose approach")
|
||||
assertLabelsContain(view, "Minimal")
|
||||
assertLabelsContain(view, "Choose test level")
|
||||
assertLabelsContain(view, "Unit")
|
||||
|
||||
// Submit from review page
|
||||
button(view, "Submit").doClick()
|
||||
|
||||
assertFalse(view.isVisible)
|
||||
@@ -187,6 +197,26 @@ class QuestionViewTest : BasePlatformTestCase() {
|
||||
assertEquals(listOf(listOf("Minimal"), listOf("Unit")), replies.single().second.answers)
|
||||
}
|
||||
|
||||
fun `test multi question uses review before submit`() {
|
||||
view.show(twoItemQuestion("q_review"))
|
||||
|
||||
option<JBRadioButton>(view, "Minimal").doClick()
|
||||
button(view, "Next").doClick()
|
||||
option<JBRadioButton>(view, "Unit").doClick()
|
||||
|
||||
// Clicking Review should NOT submit
|
||||
button(view, "Review").doClick()
|
||||
|
||||
assertTrue("Should still be visible after Review", view.isVisible)
|
||||
assertTrue("No replies should be sent after Review", replies.isEmpty())
|
||||
|
||||
// Now submit from review page
|
||||
button(view, "Submit").doClick()
|
||||
|
||||
assertFalse(view.isVisible)
|
||||
assertEquals(1, replies.size)
|
||||
}
|
||||
|
||||
fun `test back preserves previous selection`() {
|
||||
view.show(twoItemQuestion("q_back"))
|
||||
|
||||
@@ -203,15 +233,71 @@ class QuestionViewTest : BasePlatformTestCase() {
|
||||
val radios = findAll<JBRadioButton>(view)
|
||||
assertTrue("Minimal should still be selected", radios.first { it.actionCommand == "Minimal" }.isSelected)
|
||||
|
||||
// Change selection to Balanced, go forward, submit
|
||||
// Change selection to Balanced, go forward, then to review
|
||||
option<JBRadioButton>(view, "Balanced").doClick()
|
||||
button(view, "Next").doClick()
|
||||
option<JBRadioButton>(view, "Unit").doClick()
|
||||
button(view, "Review").doClick()
|
||||
button(view, "Submit").doClick()
|
||||
|
||||
assertEquals(listOf(listOf("Balanced"), listOf("Unit")), replies.single().second.answers)
|
||||
}
|
||||
|
||||
fun `test review back preserves answers`() {
|
||||
view.show(twoItemQuestion("q_review_back"))
|
||||
|
||||
option<JBRadioButton>(view, "Minimal").doClick()
|
||||
button(view, "Next").doClick()
|
||||
option<JBRadioButton>(view, "Unit").doClick()
|
||||
button(view, "Review").doClick()
|
||||
|
||||
// On review page - go back to last question
|
||||
button(view, "Back").doClick()
|
||||
|
||||
// Back on second question — selection should be preserved
|
||||
assertLabelsContain(view, "Choose test level")
|
||||
assertLabelsContain(view, "2 of 2 questions")
|
||||
val radios = findAll<JBRadioButton>(view)
|
||||
assertTrue("Unit should still be selected", radios.first { it.actionCommand == "Unit" }.isSelected)
|
||||
}
|
||||
|
||||
fun `test review displays multi select answers joined`() {
|
||||
val q = Question(
|
||||
id = "q_multi",
|
||||
items = listOf(
|
||||
QuestionItem(
|
||||
question = "Choose features",
|
||||
header = "Features",
|
||||
options = listOf(
|
||||
QuestionOption("A", "Feature A"),
|
||||
QuestionOption("B", "Feature B"),
|
||||
),
|
||||
multiple = true,
|
||||
custom = false,
|
||||
),
|
||||
),
|
||||
)
|
||||
view.show(q)
|
||||
|
||||
option<JBCheckBox>(view, "A").doClick()
|
||||
option<JBCheckBox>(view, "B").doClick()
|
||||
button(view, "Review").doClick()
|
||||
|
||||
assertLabelsContain(view, "A, B")
|
||||
}
|
||||
|
||||
fun `test single select question still submits directly`() {
|
||||
view.show(singleSelectQuestion("q_direct"))
|
||||
|
||||
option<JBRadioButton>(view, "Minimal").doClick()
|
||||
button(view, "Submit").doClick()
|
||||
|
||||
// Should submit directly without a review step
|
||||
assertFalse(view.isVisible)
|
||||
assertEquals(1, replies.size)
|
||||
assertEquals(listOf(listOf("Minimal")), replies.single().second.answers)
|
||||
}
|
||||
|
||||
fun `test next is disabled until current question is answered`() {
|
||||
view.show(twoItemQuestion("q_disabled"))
|
||||
|
||||
@@ -234,12 +320,16 @@ class QuestionViewTest : BasePlatformTestCase() {
|
||||
assertNotNull("Forward should have disabled icon", navButton(view, "Next").disabledIcon)
|
||||
|
||||
option<JBRadioButton>(view, "Minimal").doClick()
|
||||
assertTrue("Forward should be enabled after selection", navButton(view, "Next").isEnabled)
|
||||
assertTrue("Forward should be enabled after selection on first question", navButton(view, "Next").isEnabled)
|
||||
|
||||
button(view, "Next").doClick()
|
||||
|
||||
assertTrue("Back should be enabled on second question", navButton(view, "Back").isEnabled)
|
||||
assertFalse("Forward should be disabled on last question", navButton(view, "Next").isEnabled)
|
||||
// Forward on last question is enabled only after selection (can go to review)
|
||||
assertFalse("Forward should be disabled on last question before selection", navButton(view, "Next").isEnabled)
|
||||
|
||||
option<JBRadioButton>(view, "Unit").doClick()
|
||||
assertTrue("Forward should be enabled on last question after selection (goes to review)", navButton(view, "Next").isEnabled)
|
||||
}
|
||||
|
||||
fun `test submit button uses default style`() {
|
||||
@@ -304,6 +394,9 @@ class QuestionViewTest : BasePlatformTestCase() {
|
||||
boxes.first { it.actionCommand == "B" }.doClick()
|
||||
// Toggle B off — need fresh refs after re-render
|
||||
option<JBCheckBox>(view, "B").doClick()
|
||||
|
||||
// Single multi-select item gets a Review step (VS Code parity: single() is false when multiple=true)
|
||||
button(view, "Review").doClick()
|
||||
button(view, "Submit").doClick()
|
||||
|
||||
assertFalse(view.isVisible)
|
||||
@@ -315,7 +408,7 @@ class QuestionViewTest : BasePlatformTestCase() {
|
||||
// ------ helpers ------
|
||||
|
||||
/**
|
||||
* Find a [JButton] by button text — covers footer buttons (Dismiss, Next, Submit).
|
||||
* Find a [JButton] by button text — covers footer buttons (Dismiss, Next, Submit, Review, Back).
|
||||
* For icon-only nav buttons (Back/Forward) that use tooltip, use [navButton].
|
||||
*/
|
||||
private fun button(root: Container, text: String): JButton =
|
||||
|
||||
Reference in New Issue
Block a user