Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fuzzy-berries-press.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---

Show JetBrains session connection status inline above the prompt, cap expanded connection details, and keep empty sessions in the shared chat scroll area.
5 changes: 5 additions & 0 deletions .changeset/green-planes-reply.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---

Show detailed JetBrains session connection and configuration issues in the connection panel, and make retry recover app-side problems more reliably.
5 changes: 5 additions & 0 deletions .changeset/slow-terms-dance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---

Keep the JetBrains session connection status above the prompt without shifting the chat layout.
5 changes: 5 additions & 0 deletions .changeset/soft-garlic-care.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---

Refine JetBrains session loading UI and settings actions.
5 changes: 5 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,8 @@ end_of_line = lf
indent_style = space
indent_size = 2
max_line_length = 80

[*.{kt,kts}]
indent_size = 4
tab_width = 4
ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ data class LoadError(
val detail: String? = null,
)

data class ConfigWarning(
val path: String,
val message: String,
val detail: String? = null,
)

/**
* All global data that has been successfully loaded.
* Present only in [KiloAppState.Ready].
Expand All @@ -48,4 +54,5 @@ data class AppData(
val profile: KiloProfile200Response?,
val config: Config,
val notifications: List<KiloNotifications200ResponseInner>,
val warnings: List<ConfigWarning> = emptyList(),
)
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import ai.kilocode.jetbrains.api.infrastructure.ClientException
import ai.kilocode.jetbrains.api.infrastructure.ServerError
import ai.kilocode.jetbrains.api.infrastructure.ServerException
import ai.kilocode.jetbrains.api.model.Config
import ai.kilocode.jetbrains.api.model.ConfigWarnings200ResponseInner
import ai.kilocode.jetbrains.api.model.KiloNotifications200ResponseInner
import ai.kilocode.jetbrains.api.model.KiloProfile200Response
import ai.kilocode.rpc.dto.HealthDto
Expand All @@ -25,6 +26,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import okhttp3.OkHttpClient
Expand Down Expand Up @@ -107,6 +109,9 @@ class KiloBackendAppService private constructor(
@Volatile var notifications: List<KiloNotifications200ResponseInner> = emptyList()
private set

@Volatile var warnings: List<ConfigWarning> = emptyList()
private set

suspend fun connect() {
mutex.withLock {
val current = _appState.value
Expand All @@ -130,6 +135,42 @@ class KiloBackendAppService private constructor(
}
}

suspend fun retry() {
mutex.withLock {
when (val current = _appState.value) {
KiloAppState.Disconnected -> {
ensureWatcher()
connection.connect()
}
KiloAppState.Connecting,
is KiloAppState.Loading -> Unit
is KiloAppState.Ready -> {
if (current.data.warnings.isEmpty()) return
log.info("retry: refreshing config warnings")
refreshConfigState()
val next = _appState.value
val warns = (next as? KiloAppState.Ready)?.data?.warnings
if (next is KiloAppState.Ready && warns.isNullOrEmpty()) return
restartConnection("warnings remained after refresh")
}
is KiloAppState.Error -> {
val load = current.errors.none { it.resource == "connection" }
if (load && connection.api != null) {
log.info("retry: rerunning app load from ${current.message}")
val prev = _appState.value
load()
val next = awaitLoadResult(prev)
val warns = (next as? KiloAppState.Ready)?.data?.warnings
if (next is KiloAppState.Ready && warns.isNullOrEmpty()) return
restartConnection("state remained problematic after load retry")
return
}
restartConnection("connection error: ${current.message}")
}
}
}
}

/** One-shot health check via the generated API client. */
suspend fun health(): HealthDto {
val client = api ?: throw IllegalStateException("Not connected")
Expand Down Expand Up @@ -157,7 +198,12 @@ class KiloBackendAppService private constructor(
ConnectionState.Disconnected -> _appState.value = KiloAppState.Disconnected
ConnectionState.Connecting -> _appState.value = KiloAppState.Connecting
is ConnectionState.Connected -> load()
is ConnectionState.Error -> _appState.value = KiloAppState.Error(next.message)
is ConnectionState.Error -> setAppError(
message = next.message,
errors = next.details?.let {
listOf(LoadError(resource = "connection", detail = it))
} ?: emptyList(),
)
}
}
}
Expand Down Expand Up @@ -186,6 +232,7 @@ class KiloBackendAppService private constructor(
var cfg: Config? = null
var prof: KiloProfile200Response? = null
var notifs: List<KiloNotifications200ResponseInner> = emptyList()
var warns: List<ConfigWarning> = emptyList()

try {
coroutineScope {
Expand Down Expand Up @@ -229,6 +276,9 @@ class KiloBackendAppService private constructor(
throw LoadFailure(err)
}
}
launch {
warns = fetchWarnings()
}
}

ensureActive()
Expand All @@ -238,11 +288,12 @@ class KiloBackendAppService private constructor(
sessions.start(connection.api!!, connection.apiClient!!, connection.port, connection.events)
chat.start(connection.apiClient!!, connection.port, connection.events)
workspaces.start(connection.api!!, connection.events)
_appState.value = KiloAppState.Ready(
setAppReady(
AppData(
profile = prof,
config = cfg!!,
notifications = notifs,
warnings = warns,
)
)
log.info("Application started — config, profile, notifications loaded")
Expand All @@ -251,7 +302,7 @@ class KiloBackendAppService private constructor(
throw e
} catch (e: Exception) {
log.warn("Application start failed: ${e.message}")
_appState.value = KiloAppState.Error(
setAppError(
message = "Failed to load required data",
errors = errors.toList(),
)
Expand Down Expand Up @@ -321,6 +372,81 @@ class KiloBackendAppService private constructor(
}
}

private suspend fun fetchWarnings(): List<ConfigWarning> {
val client = connection.api ?: return emptyList()
return try {
client.configWarnings().map(::warning)
} catch (e: Exception) {
log.warn("Config warnings fetch failed: ${e.message}", e)
emptyList()
}
}

private fun warning(w: ConfigWarnings200ResponseInner) = ConfigWarning(
path = w.path,
message = w.message,
detail = w.detail,
)

private suspend fun refreshConfigState() {
val current = _appState.value as? KiloAppState.Ready ?: return
Comment thread
kirillk marked this conversation as resolved.
val cfg = fetchConfig().value ?: return
val warns = fetchWarnings()
config = cfg
setAppReady(
current.data.copy(
config = cfg,
warnings = warns,
)
)
}

private fun setAppReady(data: AppData) {
warnings = data.warnings
_appState.value = KiloAppState.Ready(data)
if (data.warnings.isNotEmpty()) warnAppWarnings(data.warnings)
}

private fun setAppError(message: String, errors: List<LoadError>) {
val state = KiloAppState.Error(message, errors)
_appState.value = state
warnAppError(state)
}

private fun warnAppError(state: KiloAppState.Error) {
val text = if (state.errors.isEmpty()) state.message
else "${state.message} [${state.errors.joinToString("; ") { error(it) }}]"
log.warn("App error: $text")
}

private fun warnAppWarnings(warnings: List<ConfigWarning>) {
val text = warnings.joinToString("; ") { warning(it) }
log.warn("App warnings: $text")
}

private fun error(err: LoadError): String {
val status = err.status?.let { " status=$it" } ?: ""
val detail = err.detail?.let { " detail=$it" } ?: ""
return "${err.resource}$status$detail"
}

private fun warning(warn: ConfigWarning): String {
val detail = warn.detail?.let { " detail=$it" } ?: ""
return "${warn.path}: ${warn.message}$detail"
}

private suspend fun restartConnection(reason: String) {
clear()
connection.restart()
log.info("retry: restarted connection ($reason)")
}

private suspend fun awaitLoadResult(prev: KiloAppState): KiloAppState {
val next = appState.first { it !== prev }
if (next !is KiloAppState.Loading) return next
return appState.first { it !is KiloAppState.Loading }
}

/**
* Dump the HTTP response body from a failed API call for debugging.
* The generated client wraps the response in [ClientException.response]
Expand Down Expand Up @@ -380,17 +506,8 @@ class KiloBackendAppService private constructor(
"global.config.updated" -> {
log.info("SSE global.config.updated — reloading config")
launch {
val result = fetchConfig()
if (result.value != null) {
config = result.value
val current = _appState.value
if (current is KiloAppState.Ready) {
_appState.value = current.copy(
data = current.data.copy(config = result.value)
)
}
log.info("Config reloaded successfully")
}
refreshConfigState()
log.info("Config reloaded successfully")
}
}
"global.disposed" -> {
Expand Down Expand Up @@ -420,6 +537,7 @@ class KiloBackendAppService private constructor(
profile = null
config = null
notifications = emptyList()
warnings = emptyList()
_appState.value = KiloAppState.Disconnected
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ sealed class ConnectionState {
data object Disconnected : ConnectionState()
data object Connecting : ConnectionState()
data class Connected(val port: Int, val password: String) : ConnectionState()
data class Error(val message: String) : ConnectionState()
data class Error(val message: String, val details: String? = null) : ConnectionState()
}

data class SseEvent(val type: String, val data: String)
Expand Down Expand Up @@ -165,7 +165,7 @@ class KiloConnectionService(
val result = server.init()

if (result is CliServer.State.Error) {
setState(ConnectionState.Error(result.message))
setState(ConnectionState.Error(result.message, result.details))
return
}

Expand Down Expand Up @@ -230,12 +230,17 @@ class KiloConnectionService(
}

override fun onFailure(src: EventSource, t: Throwable?, response: Response?) {
val detail = when {
t != null -> t.stackTraceToString()
response != null -> response.body?.string()
else -> null
}?.trim()?.ifEmpty { null }
if (t != null) {
log.warn("SSE: failure (${t.message}) — scheduling reconnect")
} else {
log.warn("SSE: failure (HTTP ${response?.code}) — scheduling reconnect")
}
setState(ConnectionState.Error(t?.message ?: "SSE connection failed (HTTP ${response?.code})"))
setState(ConnectionState.Error(t?.message ?: "SSE connection failed (HTTP ${response?.code})", detail))
scheduleReconnect()
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ package ai.kilocode.backend.rpc

import ai.kilocode.backend.app.KiloAppState
import ai.kilocode.backend.app.KiloBackendAppService
import ai.kilocode.backend.app.ConfigWarning
import ai.kilocode.backend.app.LoadError
import ai.kilocode.backend.app.LoadProgress
import ai.kilocode.backend.app.ProfileResult
import ai.kilocode.rpc.KiloAppRpcApi
import ai.kilocode.rpc.dto.ConfigWarningDto
import ai.kilocode.rpc.dto.HealthDto
import ai.kilocode.rpc.dto.KiloAppStateDto
import ai.kilocode.rpc.dto.KiloAppStatusDto
Expand Down Expand Up @@ -36,6 +38,8 @@ class KiloAppRpcApiImpl : KiloAppRpcApi {

override suspend fun health(): HealthDto = app.health()

override suspend fun retry() = app.retry()

override suspend fun restart() = app.restart()

override suspend fun reinstall() = app.reinstall()
Expand All @@ -56,6 +60,7 @@ class KiloAppRpcApiImpl : KiloAppRpcApi {
profile = if (state.data.profile != null) ProfileStatusDto.LOADED
else ProfileStatusDto.NOT_LOGGED_IN,
),
warnings = state.data.warnings.map(::warning),
)
is KiloAppState.Error -> KiloAppStateDto(
status = KiloAppStatusDto.ERROR,
Expand All @@ -79,4 +84,10 @@ class KiloAppRpcApiImpl : KiloAppRpcApi {
status = e.status,
detail = e.detail,
)

private fun warning(w: ConfigWarning) = ConfigWarningDto(
path = w.path,
message = w.message,
detail = w.detail,
)
}
Loading
Loading