fix(jetbrains): show failed turn outcomes in chat

This commit is contained in:
kirillk
2026-08-14 14:59:06 -04:00
parent 3219e32e6c
commit b1a8893f14
12 changed files with 507 additions and 14 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": minor
---
Show provider errors and interrupted or failed turns in the JetBrains chat instead of returning silently to idle.
@@ -50,6 +50,7 @@ import ai.kilocode.client.session.controller.SessionControllerEvent
import ai.kilocode.client.session.context.EditorContextGatherer
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.views.LoginRequiredView
import ai.kilocode.client.session.views.SessionOutcomeView
import ai.kilocode.client.session.views.permission.PermissionView
import ai.kilocode.client.session.views.question.QuestionView
import ai.kilocode.client.settings.KiloSettingsConfigurable
@@ -188,6 +189,7 @@ class SessionUi(
private lateinit var question: QuestionView
private lateinit var permission: PermissionView
private lateinit var login: LoginRequiredView
private lateinit var outcome: SessionOutcomeView
private lateinit var connection: ConnectionPanel
private lateinit var prompt: PromptPanel
@@ -260,7 +262,8 @@ class SessionUi(
is SessionState.Reverting,
is SessionState.Retry,
is SessionState.Offline,
is SessionState.Error -> null
is SessionState.Error,
is SessionState.TurnEnded -> null
is SessionState.LoginRequired -> SessionActivityKind.LOGIN_REQUIRED
is SessionState.AwaitingPermission -> SessionActivityKind.PERMISSION
is SessionState.AwaitingQuestion ->
@@ -360,6 +363,10 @@ class SessionUi(
selection = selection,
focus = focus,
)
outcome = SessionOutcomeView(
selection = selection,
focus = focus,
)
messageBody = SessionMessageListPanel(
controller.model,
this,
@@ -377,6 +384,7 @@ class SessionUi(
deleteQueued = { id -> controller.deleteQueuedMessage(id) },
banner = RevertBanner(controller.model, ::redo, controller::redoAll, ::cancelRevert, focus),
).also {
it.outcome = outcome
it.setDiffOpener(::openInlineDiff, controller.id)
it.onHover = { view, on -> if (on) popup.show(view) else popup.notifyExit(view) }
}
@@ -952,7 +960,7 @@ class SessionUi(
if (wasBusy && state is SessionState.Idle) refreshBranchChanges()
wasBusy = busy
if (state is SessionState.Reverting) overlay.clear()
if (state is SessionState.Error) {
if (state is SessionState.Error || state is SessionState.TurnEnded) {
pendingRollback = null
pendingRedo = null
}
@@ -22,6 +22,9 @@ import ai.kilocode.client.session.model.QuestionOption
import ai.kilocode.client.session.model.Reasoning
import ai.kilocode.client.session.model.ToolCallRef
import ai.kilocode.client.session.model.Text
import ai.kilocode.client.session.model.Outcome
import ai.kilocode.client.session.model.OutcomeTone
import ai.kilocode.client.session.model.TurnOutcome
import ai.kilocode.client.plugin.KiloPluginSettings
import ai.kilocode.client.session.SessionRef
import ai.kilocode.client.telemetry.Telemetry
@@ -120,6 +123,7 @@ class SessionController(
companion object {
private val LOG = KiloLog.create(SessionController::class.java)
private const val ABORT_ERROR = "MessageAbortedError"
internal const val RECENT_LIMIT = 5
internal const val DISPLAY_DELAY_MS = 1_000L
internal const val REVERT_TIMEOUT_MS = 30_000L
@@ -1310,6 +1314,8 @@ class SessionController(
model.setState(SessionState.AwaitingQuestion(toQuestion(questions.last())))
} else if (status != null) {
seedStatus(status)
} else {
seedOutcome()
}
}
}
@@ -1342,6 +1348,15 @@ class SessionController(
model.setState(state)
}
private fun seedOutcome() {
val err = model.messages().lastOrNull { it.info.role == "assistant" }?.info?.error ?: return
if (err.type == ABORT_ERROR) {
model.setState(SessionState.TurnEnded(Outcome.INTERRUPTED, OutcomeTone.WARNING))
return
}
model.setState(SessionState.Error(err.message ?: err.type, err.type))
}
private fun handle(event: ChatEventDto) {
LOG.debug { ChatLogSummary.event(event) }
when (event) {
@@ -1419,20 +1434,25 @@ class SessionController(
val current = model.state
if (current is SessionState.AwaitingQuestion) return
if (current is SessionState.AwaitingPermission) return
val clobberOk = event.reason == "completed"
|| current is SessionState.Busy
|| current is SessionState.Retry
|| current is SessionState.Offline
if (clobberOk) {
if (event.reason == "completed") capture("Task Completed", sessionProps(event.sessionID))
model.setState(SessionState.Idle)
if (current is SessionState.LoginRequired) return
if (current is SessionState.Error && event.reason != "completed") return
val ended = TurnOutcome.classify(event.reason, current is SessionState.Error)
when {
ended != null -> model.setState(SessionState.TurnEnded(ended.first, ended.second))
event.reason == "completed" -> {
capture("Task Completed", sessionProps(event.sessionID))
model.setState(SessionState.Idle)
}
current is SessionState.Busy || current is SessionState.Retry || current is SessionState.Offline -> model.setState(SessionState.Idle)
}
}
is ChatEventDto.SessionCreated -> adoptFollowup(event.info)
is ChatEventDto.Error -> {
capture("Session Error", sessionProps(event.sessionID) + mapOf("context" to "event", "errorClass" to (event.error?.type ?: "unknown")))
if (event.error?.type != ABORT_ERROR) {
capture("Session Error", sessionProps(event.sessionID) + mapOf("context" to "event", "errorClass" to (event.error?.type ?: "unknown")))
}
error(event, true)
}
@@ -1553,6 +1573,7 @@ class SessionController(
model.setState(SessionState.LoginRequired(KiloBundle.message("session.login.required.description")))
return
}
if (event.error?.type == ABORT_ERROR) return
val msg = event.error?.message ?: event.error?.type ?: KiloBundle.message("session.error.unknown")
model.setState(SessionState.Error(msg, event.error?.type))
}
@@ -1657,7 +1678,11 @@ class SessionController(
val state = when (dto.type) {
"idle" -> {
val current = model.state
if (current is SessionState.LoginRequired || current is SessionState.Reverting) return
if (current is SessionState.Error
|| current is SessionState.TurnEnded
|| current is SessionState.LoginRequired
|| current is SessionState.Reverting
) return
purgePending(sid)
// purgePending may promote a still-queued permission from another (unpurged) child
// session; mirror idle() and leave that card in place rather than clobbering it with Idle.
@@ -1666,7 +1691,7 @@ class SessionController(
}
"busy" -> {
val current = model.state
if (current is SessionState.Idle || current is SessionState.Error)
if (current is SessionState.Idle || current is SessionState.Error || current is SessionState.TurnEnded)
SessionState.Busy(KiloBundle.message("session.status.considering"))
else return // already in a more specific phase
}
@@ -1770,6 +1795,7 @@ class SessionController(
// Only apply if we're not in a more specific non-terminal state.
val current = model.state
if (current !is SessionState.Error
&& current !is SessionState.TurnEnded
&& current !is SessionState.AwaitingPermission
&& current !is SessionState.AwaitingQuestion
&& current !is SessionState.LoginRequired
@@ -2430,6 +2456,7 @@ class SessionController(
out.add("[error]")
out.add("[${state.message}]")
}
is SessionState.TurnEnded -> out.add("[${state.outcome.name.lowercase()}]")
is SessionState.LoginRequired -> {
out.add("[login-required]")
out.add("[${state.message}]")
@@ -22,10 +22,12 @@ sealed class SessionState {
data class Error(val message: String, val kind: String? = null) : SessionState()
data class TurnEnded(val outcome: Outcome, val tone: OutcomeTone) : SessionState()
data class LoginRequired(val message: String) : SessionState()
fun isBusy(): Boolean = when (this) {
is Idle, is Loading, is Error, is LoginRequired -> false
is Idle, is Loading, is Error, is TurnEnded, is LoginRequired -> false
else -> true
}
}
@@ -0,0 +1,14 @@
package ai.kilocode.client.session.model
enum class Outcome { INTERRUPTED, FAILED }
enum class OutcomeTone { WARNING, CRITICAL }
object TurnOutcome {
fun classify(reason: String, provider: Boolean): Pair<Outcome, OutcomeTone>? = when {
provider -> null
reason == "interrupted" -> Outcome.INTERRUPTED to OutcomeTone.WARNING
reason == "error" -> Outcome.FAILED to OutcomeTone.CRITICAL
else -> null
}
}
@@ -13,6 +13,7 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.views.LoginRequiredView
import ai.kilocode.client.session.views.MessageView
import ai.kilocode.client.session.views.SessionOutcomeView
import ai.kilocode.client.session.views.permission.PermissionView
import ai.kilocode.client.session.views.question.QuestionView
import ai.kilocode.client.session.views.TurnView
@@ -91,6 +92,16 @@ class SessionMessageListPanel(
var onHover: ((PartView, Boolean) -> Unit)? = null
var onReflow: ((Boolean) -> Unit)? = null
var outcome: SessionOutcomeView? = null
set(value) {
if (field === value) return
field?.let { remove(it) }
field = value
value?.applyStyle(style)
syncActive(model.state)
anchorFooter()
refresh()
}
/** Progress footer — always the last child inside the scroll. */
val progress = ProgressPanel(model, parent)
@@ -424,25 +435,43 @@ class SessionMessageListPanel(
setHiddenQuestionTool(state.question.tool)
permission?.hideView()
login?.hideView()
outcome?.hideView()
question?.show(state.question)
}
is SessionState.AwaitingPermission -> {
setHiddenQuestionTool(null)
question?.hideView()
login?.hideView()
outcome?.hideView()
permission?.show(state.permission)
}
is SessionState.LoginRequired -> {
setHiddenQuestionTool(null)
question?.hideView()
permission?.hideView()
outcome?.hideView()
login?.show(state.message)
}
is SessionState.Error -> {
setHiddenQuestionTool(null)
question?.hideView()
permission?.hideView()
login?.hideView()
outcome?.showError(state.message, state.kind)
}
is SessionState.TurnEnded -> {
setHiddenQuestionTool(null)
question?.hideView()
permission?.hideView()
login?.hideView()
outcome?.showOutcome(state.outcome, state.tone)
}
else -> {
setHiddenQuestionTool(null)
question?.hideView()
permission?.hideView()
login?.hideView()
outcome?.hideView()
}
}
}
@@ -494,11 +523,13 @@ class SessionMessageListPanel(
if (question != null) remove(question)
if (permission != null) remove(permission)
if (login != null) remove(login)
if (outcome != null) remove(outcome)
if (banner != null) remove(banner)
remove(progress)
if (question != null) add(question)
if (permission != null) add(permission)
if (login != null) add(login)
if (outcome != null) add(outcome)
if (banner != null) add(banner)
add(progress)
}
@@ -616,6 +647,7 @@ class SessionMessageListPanel(
question?.applyStyle(style)
permission?.applyStyle(style)
login?.applyStyle(style)
outcome?.applyStyle(style)
banner?.applyStyle(style)
progress.applyStyle(style)
reflow()
@@ -630,6 +662,7 @@ class SessionMessageListPanel(
question?.hideView()
permission?.hideView()
login?.hideView()
outcome?.hideView()
turnViews.values.forEach {
remove(it)
Disposer.dispose(it)
@@ -0,0 +1,77 @@
package ai.kilocode.client.session.views
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.model.Outcome
import ai.kilocode.client.session.model.OutcomeTone
import ai.kilocode.client.session.ui.SessionView
import ai.kilocode.client.session.ui.selection.SessionSelection
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
import ai.kilocode.client.session.views.base.BaseQuestionView
import com.intellij.icons.AllIcons
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.util.ui.components.BorderLayoutPanel
class SessionOutcomeView(
selection: SessionSelection? = null,
focus: (() -> Unit)? = null,
) : BorderLayoutPanel(), SessionEditorStyleTarget, SessionView {
override val sessionViewKind = SessionView.Kind.Default
private val card = BaseQuestionView(selection, focus)
init {
isOpaque = false
isVisible = false
card.setActions(emptyList())
addToCenter(card)
}
@RequiresEdt
fun showError(message: String, kind: String?) {
card.setHeaderIcon(AllIcons.General.Error, kind ?: KiloBundle.message("session.error.title"))
card.setHeader(KiloBundle.message("session.error.title"), message)
isVisible = true
refresh()
}
@RequiresEdt
fun showOutcome(outcome: Outcome, tone: OutcomeTone) {
val title = when (outcome) {
Outcome.INTERRUPTED -> KiloBundle.message("session.outcome.interrupted.title")
Outcome.FAILED -> KiloBundle.message("session.outcome.failed.title")
}
val desc = when (outcome) {
Outcome.INTERRUPTED -> KiloBundle.message("session.outcome.interrupted.description")
Outcome.FAILED -> KiloBundle.message("session.outcome.failed.description")
}
val icon = when (tone) {
OutcomeTone.WARNING -> AllIcons.General.Warning
OutcomeTone.CRITICAL -> AllIcons.General.Error
}
card.setHeaderIcon(icon, title)
card.setHeader(title, desc)
isVisible = true
refresh()
}
@RequiresEdt
fun hideView() {
if (!isVisible) return
isVisible = false
refresh()
}
@RequiresEdt
override fun applyStyle(style: SessionEditorStyle) {
card.applyStyle(style)
}
private fun refresh() {
revalidate()
repaint()
parent?.revalidate()
parent?.repaint()
}
}
@@ -177,7 +177,12 @@ session.part.todo.hidden.later.many={0} later to-dos hidden
session.error.prompt=Prompt failed
session.error.compact=Session compact failed
session.error.title=Request failed
session.error.unknown=Unknown error
session.outcome.failed.description=The model stopped this turn with an error.
session.outcome.failed.title=Response failed
session.outcome.interrupted.description=This turn was interrupted before it finished.
session.outcome.interrupted.title=Response stopped
session.login.required.title=You need to sign in to use this model
session.login.required.description=Go to User Profile settings to sign in, then continue this session.
@@ -4,6 +4,7 @@ import ai.kilocode.client.session.model.SessionState
import ai.kilocode.rpc.dto.ConfigDto
import ai.kilocode.rpc.dto.KiloAppStateDto
import ai.kilocode.rpc.dto.KiloAppStatusDto
import ai.kilocode.rpc.dto.MessageErrorDto
import ai.kilocode.rpc.dto.MessageWithPartsDto
import ai.kilocode.rpc.dto.PartDto
import ai.kilocode.rpc.dto.PermissionRequestDto
@@ -82,6 +83,46 @@ class SessionRecoveryTest : SessionControllerTestBase() {
assertEquals(SessionState.Idle, m.model.state)
}
fun `test provider error is recovered from assistant history`() {
rpc.history.add(MessageWithPartsDto(
msg("msg1", "ses_test", "assistant").copy(error = MessageErrorDto(type = "APIError", message = "OpenRouter balance is too low")),
emptyList(),
))
projectRpc.state.value = workspaceReady()
val m = controller("ses_test")
flush()
assertSession(
"""
assistant#msg1
[code] [kilo/gpt-5] [error] [OpenRouter balance is too low]
""",
m,
)
}
fun `test aborted assistant history recovers interrupted outcome`() {
rpc.history.add(MessageWithPartsDto(
msg("msg1", "ses_test", "assistant").copy(error = MessageErrorDto(type = "MessageAbortedError", message = "aborted")),
emptyList(),
))
projectRpc.state.value = workspaceReady()
val m = controller("ses_test")
flush()
assertSession(
"""
assistant#msg1
[code] [kilo/gpt-5] [interrupted]
""",
m,
)
}
fun `test permission takes priority over question in recovery`() {
rpc.pendingPermissionList.add(
PermissionRequestDto(
@@ -1,7 +1,10 @@
package ai.kilocode.client.session.controller
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.model.Outcome
import ai.kilocode.client.session.model.OutcomeTone
import ai.kilocode.client.session.model.SessionState
import ai.kilocode.client.session.model.TurnOutcome
import ai.kilocode.client.testing.FakeSessionRpcApi
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.ConfigDto
@@ -180,6 +183,64 @@ class TurnLifecycleTest : SessionControllerTestBase() {
)
}
fun `test TurnClose interrupted shows interrupted outcome`() {
val (m, _, _) = prompted()
emit(ChatEventDto.TurnOpen("ses_test"))
emit(ChatEventDto.TurnClose("ses_test", "interrupted"))
assertSession(
"""
[code] [kilo/gpt-5] [interrupted]
""",
m,
)
}
fun `test TurnClose error without provider error shows failed outcome`() {
val (m, _, _) = prompted()
emit(ChatEventDto.TurnOpen("ses_test"))
emit(ChatEventDto.TurnClose("ses_test", "error"))
assertSession(
"""
[code] [kilo/gpt-5] [failed]
""",
m,
)
}
fun `test abort error waits for interrupted outcome`() {
val (m, _, _) = prompted()
emit(ChatEventDto.TurnOpen("ses_test"))
emit(ChatEventDto.Error("ses_test", MessageErrorDto(type = "MessageAbortedError", message = "aborted")))
emit(ChatEventDto.TurnClose("ses_test", "interrupted"))
assertTrue(appRpc.telemetry.none { it.event == "Session Error" && it.properties["errorClass"] == "MessageAbortedError" })
assertSession(
"""
[code] [kilo/gpt-5] [interrupted]
""",
m,
)
}
fun `test provider error wins over interrupted close`() {
val (m, _, _) = prompted()
emit(ChatEventDto.Error("ses_test", MessageErrorDto(type = "APIError", message = "OpenRouter balance is too low")))
emit(ChatEventDto.TurnClose("ses_test", "interrupted"))
assertSession(
"""
[code] [kilo/gpt-5] [error] [OpenRouter balance is too low]
""",
m,
)
}
fun `test TurnClose completed clobbers Error state`() {
val (m, _, _) = prompted()
@@ -195,6 +256,30 @@ class TurnLifecycleTest : SessionControllerTestBase() {
)
}
fun `test TurnClose completed clobbers outcome state`() {
val (m, _, _) = prompted()
emit(ChatEventDto.TurnOpen("ses_test"))
emit(ChatEventDto.TurnClose("ses_test", "interrupted"))
emit(ChatEventDto.TurnClose("ses_test", "completed"))
assertSession(
"""
[code] [kilo/gpt-5] [idle]
""",
m,
)
}
fun `test turn outcome classifier`() {
assertNull(TurnOutcome.classify("completed", false))
assertNull(TurnOutcome.classify("superseded", false))
assertEquals(Outcome.INTERRUPTED to OutcomeTone.WARNING, TurnOutcome.classify("interrupted", false))
assertEquals(Outcome.FAILED to OutcomeTone.CRITICAL, TurnOutcome.classify("error", false))
assertNull(TurnOutcome.classify("error", true))
assertNull(TurnOutcome.classify("interrupted", true))
}
fun `test TurnClose completed preserves AwaitingQuestion state`() {
val (m, _, _) = prompted()
@@ -291,6 +376,51 @@ class TurnLifecycleTest : SessionControllerTestBase() {
)
}
fun `test SessionStatus idle does not clobber Error state`() {
val (m, _, _) = prompted()
emit(ChatEventDto.Error("ses_test", MessageErrorDto(type = "timeout", message = "Timed out")))
emit(ChatEventDto.SessionStatusChanged("ses_test", SessionStatusDto("idle")))
assertSession(
"""
[code] [kilo/gpt-5] [error] [Timed out]
""",
m,
)
}
fun `test idle signals do not clobber turn outcome`() {
val (m, _, _) = prompted()
emit(ChatEventDto.TurnOpen("ses_test"))
emit(ChatEventDto.TurnClose("ses_test", "interrupted"))
emit(ChatEventDto.SessionIdle("ses_test"))
emit(ChatEventDto.SessionStatusChanged("ses_test", SessionStatusDto("idle")))
assertSession(
"""
[code] [kilo/gpt-5] [interrupted]
""",
m,
)
}
fun `test busy status clears turn outcome`() {
val (m, _, _) = prompted()
emit(ChatEventDto.TurnOpen("ses_test"))
emit(ChatEventDto.TurnClose("ses_test", "interrupted"))
emit(ChatEventDto.SessionStatusChanged("ses_test", SessionStatusDto("busy")))
assertSession(
"""
[code] [kilo/gpt-5] [busy] [considering next steps]
""",
m,
)
}
fun `test paid model auth error enters login required state`() {
val (m, _, _) = prompted()
@@ -3,6 +3,8 @@ package ai.kilocode.client.session.ui
import ai.kilocode.client.session.SessionFileOpener
import ai.kilocode.client.session.model.Permission
import ai.kilocode.client.session.model.PermissionMeta
import ai.kilocode.client.session.model.Outcome
import ai.kilocode.client.session.model.OutcomeTone
import ai.kilocode.client.session.model.Question
import ai.kilocode.client.session.model.QuestionItem
import ai.kilocode.client.session.model.QuestionOption
@@ -14,6 +16,7 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.views.LoginRequiredView
import ai.kilocode.client.session.views.PlanExitView
import ai.kilocode.client.session.views.SessionOutcomeView
import ai.kilocode.client.session.views.base.BaseQuestionView
import ai.kilocode.client.session.views.base.PartHeader
import ai.kilocode.client.session.views.permission.PermissionView
@@ -52,6 +55,7 @@ import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.ui.components.ActionLink
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBScrollPane
import com.intellij.ui.components.JBTextArea
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import java.awt.Color
@@ -1074,6 +1078,47 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
assertSame(item.progress, item.components.last())
}
fun `test error state makes outcome view visible and hides others`() {
val item = panelWithPrompts()
model.setState(SessionState.Error("OpenRouter balance is too low", "APIError"))
val ov = find<SessionOutcomeView>(item)!!
val qv = find<QuestionView>(item)!!
val pv = find<PermissionView>(item)!!
val lv = find<LoginRequiredView>(item)!!
assertTrue(ov.isVisible)
assertFalse(qv.isVisible)
assertFalse(pv.isVisible)
assertFalse(lv.isVisible)
assertNotNull(text(item, "OpenRouter balance is too low"))
assertSame(item.progress, item.components.last())
}
fun `test turn ended state makes outcome view visible`() {
val item = panelWithPrompts()
model.setState(SessionState.TurnEnded(Outcome.INTERRUPTED, OutcomeTone.WARNING))
val ov = find<SessionOutcomeView>(item)!!
val comps = item.components.toList()
assertTrue(ov.isVisible)
assertNotNull(text(item, KiloBundle.message("session.outcome.interrupted.description")))
assertTrue(comps.indexOf(ov) < comps.indexOf(item.progress))
assertSame(item.progress, comps.last())
}
fun `test returning to idle hides outcome view`() {
val item = panelWithPrompts()
model.setState(SessionState.TurnEnded(Outcome.FAILED, OutcomeTone.CRITICAL))
model.setState(SessionState.Idle)
val ov = find<SessionOutcomeView>(item)!!
assertFalse(ov.isVisible)
assertSame(item.progress, item.components.last())
}
fun `test login required button invokes openProfile callback`() {
var called = false
val lv = LoginRequiredView(openProfile = { called = true }, dismiss = {})
@@ -1524,11 +1569,14 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
reply = { _, _, _ -> },
)
val l = LoginRequiredView(openProfile = {}, dismiss = {})
return SessionMessageListPanel(model, parent, q, p, l, openFile)
val o = SessionOutcomeView()
return SessionMessageListPanel(model, parent, q, p, l, openFile).also { it.outcome = o }
}
private inline fun <reified T> find(root: Container): T? = findCls(root, T::class.java)
private fun text(root: Container, value: String) = components(root).filterIsInstance<JBTextArea>().firstOrNull { it.text == value }
private fun <T> findCls(root: Container, cls: Class<T>): T? {
if (cls.isInstance(root)) return cls.cast(root)
for (child in root.components) {
@@ -0,0 +1,103 @@
package ai.kilocode.client.session.views
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.model.Outcome
import ai.kilocode.client.session.model.OutcomeTone
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionUiStyle
import com.intellij.openapi.application.ApplicationManager
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBTextArea
import java.awt.Container
@Suppress("UnstableApiUsage")
class SessionOutcomeViewTest : BasePlatformTestCase() {
fun `test view is initially hidden`() {
edt {
val view = SessionOutcomeView()
assertFalse(view.isVisible)
}
}
fun `test showError renders title and message`() {
edt {
val view = SessionOutcomeView()
view.showError("OpenRouter balance is too low", "APIError")
assertTrue(view.isVisible)
assertNotNull(findText(view, KiloBundle.message("session.error.title")))
assertNotNull(findText(view, "OpenRouter balance is too low"))
}
}
fun `test showOutcome renders interrupted copy and warning icon`() {
edt {
val view = SessionOutcomeView()
view.showOutcome(Outcome.INTERRUPTED, OutcomeTone.WARNING)
assertTrue(view.isVisible)
assertNotNull(findText(view, KiloBundle.message("session.outcome.interrupted.title")))
assertNotNull(findText(view, KiloBundle.message("session.outcome.interrupted.description")))
assertTrue(findAll<JBLabel>(view).any { it.icon != null })
}
}
fun `test showOutcome updates without stale text`() {
edt {
val view = SessionOutcomeView()
view.showOutcome(Outcome.INTERRUPTED, OutcomeTone.WARNING)
view.showOutcome(Outcome.FAILED, OutcomeTone.CRITICAL)
assertNotNull(findText(view, KiloBundle.message("session.outcome.failed.title")))
assertNotNull(findText(view, KiloBundle.message("session.outcome.failed.description")))
assertNull(findText(view, KiloBundle.message("session.outcome.interrupted.description")))
}
}
fun `test hideView makes view invisible`() {
edt {
val view = SessionOutcomeView()
view.showError("Request failed", "APIError")
view.hideView()
assertFalse(view.isVisible)
}
}
fun `test description uses secondary font not editor font family`() {
edt {
val view = SessionOutcomeView()
view.showError("Provider balance is too low", "APIError")
val style = SessionEditorStyle.create(family = "Courier New", size = 20)
view.applyStyle(style)
val desc = findText(view, "Provider balance is too low")
assertNotNull(desc)
assertFalse(desc!!.font.name == "Courier New")
assertEquals(SessionUiStyle.Text.Secondary.font(style), desc.font)
}
}
private fun findText(root: Container, text: String) = findAll<JBTextArea>(root).firstOrNull { it.text == text }
private fun <T> edt(block: () -> T): T {
var result: T? = null
ApplicationManager.getApplication().invokeAndWait { result = block() }
@Suppress("UNCHECKED_CAST")
return result as T
}
private inline fun <reified T> findAll(root: Container): List<T> = findAllCls(root, T::class.java)
private fun <T> findAllCls(root: Container, cls: Class<T>): List<T> {
val result = mutableListOf<T>()
if (cls.isInstance(root)) result.add(cls.cast(root))
for (child in root.components) {
if (cls.isInstance(child)) result.add(cls.cast(child))
if (child is Container) result.addAll(findAllCls(child, cls))
}
return result
}
}