From 276276bbc61c66e3c8a6692fe00f8d4b9451fdfa Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 18 May 2026 15:15:32 -0400 Subject: [PATCH] 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. --- .../client/session/views/MessageView.kt | 16 + .../session/views/QuestionResultView.kt | 301 ++++++++++++++++++ .../client/session/views/QuestionView.kt | 160 ++++++++-- .../client/session/views/ViewFactory.kt | 14 +- .../resources/messages/KiloBundle.properties | 5 + .../session/ui/SessionMessageListPanelTest.kt | 36 ++- .../session/views/QuestionResultViewTest.kt | 238 ++++++++++++++ .../client/session/views/QuestionViewTest.kt | 103 +++++- 8 files changed, 846 insertions(+), 27 deletions(-) create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/QuestionResultView.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt index 10cc6cb2221..a01ca5838b6 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/QuestionResultView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/QuestionResultView.kt new file mode 100644 index 00000000000..165e9daa02b --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/QuestionResultView.kt @@ -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, + val answers: List>, +) + +private val json = Json { ignoreUnknownKeys = true } + +private fun parseQuestions(raw: String): List? { + 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> { + 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() + + 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() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/QuestionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/QuestionView.kt index 7b148aba512..a087c2af20a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/QuestionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/QuestionView.kt @@ -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): 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): 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() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt index d7ae3b8e9c4..b600bc0710a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt @@ -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 + } } diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index f82a12f3b98..28bc445d745 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -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… diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt index fdcd71d7c6c..925fa3c3783 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt @@ -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 = emptyMap(), + metadata: Map = emptyMap(), + ) = PartDto( id = id, sessionID = "ses", messageID = mid, type = "tool", tool = tool, callID = callId, state = state, + input = input, metadata = metadata, ) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt new file mode 100644 index 00000000000..b03ec4efd89 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt @@ -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 = emptyMap(), + metadata: Map = 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 } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt index 4b34a42b948..f04aeb19f43 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt @@ -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(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(view, "Minimal").doClick() + button(view, "Next").doClick() + option(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(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(view, "Balanced").doClick() button(view, "Next").doClick() option(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(view, "Minimal").doClick() + button(view, "Next").doClick() + option(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(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(view, "A").doClick() + option(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(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(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(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(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 =