mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-30 17:14:40 +08:00
feat(jetbrains): add KiloAppState with loading phase, retry, and error details
Introduce KiloAppState lifecycle: Disconnected → Connecting → Loading → Ready → Error. Config and notifications are required (retried 3×), profile 401 is treated as not-logged-in (not an error). Each failed fetch captures HTTP status code and error detail from ClientException/ServerException. The tool window displays granular loading progress per resource and detailed error information including HTTP status and response messages.
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
package ai.kilocode.backend
|
||||
|
||||
import ai.kilocode.jetbrains.api.model.Config
|
||||
import ai.kilocode.jetbrains.api.model.KiloNotifications200ResponseInner
|
||||
import ai.kilocode.jetbrains.api.model.KiloProfile200Response
|
||||
|
||||
/**
|
||||
* Full application lifecycle state, combining CLI transport connection
|
||||
* status with data-loading progress.
|
||||
*
|
||||
* [ConnectionState] stays internal to [KiloConnectionService] for the
|
||||
* transport layer. This sealed class is what the frontend observes.
|
||||
*/
|
||||
sealed class KiloAppState {
|
||||
data object Disconnected : KiloAppState()
|
||||
data object Connecting : KiloAppState()
|
||||
data class Loading(val progress: LoadProgress) : KiloAppState()
|
||||
data class Ready(val data: AppData) : KiloAppState()
|
||||
data class Error(val message: String, val errors: List<LoadError> = emptyList()) : KiloAppState()
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks which global data fetches have completed during the [KiloAppState.Loading] phase.
|
||||
*/
|
||||
data class LoadProgress(
|
||||
val config: Boolean = false,
|
||||
val notifications: Boolean = false,
|
||||
val profile: ProfileResult = ProfileResult.PENDING,
|
||||
)
|
||||
|
||||
/** Outcome of the profile fetch. */
|
||||
enum class ProfileResult { PENDING, LOADED, NOT_LOGGED_IN }
|
||||
|
||||
/**
|
||||
* Error detail for a single resource that failed to load.
|
||||
*/
|
||||
data class LoadError(
|
||||
val resource: String,
|
||||
val status: Int? = null,
|
||||
val detail: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* All global data that has been successfully loaded.
|
||||
* Present only in [KiloAppState.Ready].
|
||||
*/
|
||||
data class AppData(
|
||||
val profile: KiloProfile200Response?,
|
||||
val config: Config,
|
||||
val notifications: List<KiloNotifications200ResponseInner>,
|
||||
)
|
||||
+189
-48
@@ -1,6 +1,8 @@
|
||||
package ai.kilocode.backend
|
||||
|
||||
import ai.kilocode.jetbrains.api.client.DefaultApi
|
||||
import ai.kilocode.jetbrains.api.infrastructure.ClientException
|
||||
import ai.kilocode.jetbrains.api.infrastructure.ServerException
|
||||
import ai.kilocode.jetbrains.api.model.Config
|
||||
import ai.kilocode.jetbrains.api.model.KiloNotifications200ResponseInner
|
||||
import ai.kilocode.jetbrains.api.model.KiloProfile200Response
|
||||
@@ -8,14 +10,19 @@ import ai.kilocode.rpc.dto.HealthDto
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.components.Service
|
||||
import com.intellij.openapi.diagnostic.Logger
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/**
|
||||
* App-level orchestrator that owns the CLI server lifecycle and
|
||||
@@ -29,35 +36,39 @@ import kotlinx.coroutines.sync.withLock
|
||||
* [KiloBackendCliManager] and [KiloConnectionService] perform no
|
||||
* internal synchronization — they rely on this mutex.
|
||||
*
|
||||
* Data flows use the generated OpenAPI model types directly —
|
||||
* no intermediate DTOs needed at the backend layer.
|
||||
* After the CLI server connects, the app enters a [KiloAppState.Loading]
|
||||
* phase. Config and notifications are required (retried up to 3×).
|
||||
* Profile is optional — 401 (not logged in) is not an error.
|
||||
*/
|
||||
@Service(Service.Level.APP)
|
||||
class KiloBackendAppService(private val cs: CoroutineScope) : Disposable {
|
||||
|
||||
companion object {
|
||||
private val LOG = Logger.getInstance(KiloBackendAppService::class.java)
|
||||
private const val MAX_RETRIES = 3
|
||||
private const val RETRY_DELAY_MS = 1000L
|
||||
}
|
||||
|
||||
private val mutex = Mutex()
|
||||
private val server = KiloBackendCliManager()
|
||||
private val connection = KiloConnectionService(cs, server) {
|
||||
// onReconnect callback — invoked when the CLI process dies and
|
||||
// a full restart is needed. Launches under the mutex so it's
|
||||
// serialized with user-initiated connect/restart/reinstall.
|
||||
cs.launch { reconnect() }
|
||||
}
|
||||
|
||||
private var router: Job? = null
|
||||
private var loader: Job? = null
|
||||
|
||||
// ── Delegated state ─────────────────────────────────────────────
|
||||
// ── App state ───────────────────────────────────────────────────
|
||||
|
||||
private val _appState = MutableStateFlow<KiloAppState>(KiloAppState.Disconnected)
|
||||
val appState: StateFlow<KiloAppState> = _appState.asStateFlow()
|
||||
|
||||
// ── Delegated from connection (internal use) ────────────────────
|
||||
|
||||
val state: StateFlow<ConnectionState> get() = connection.state
|
||||
val events: SharedFlow<SseEvent> get() = connection.events
|
||||
val api: DefaultApi? get() = connection.api
|
||||
|
||||
// ── Global data (project-independent) ───────────────────────────
|
||||
// ── Cached data (also held inside KiloAppState.Ready) ───────────
|
||||
|
||||
@Volatile var profile: KiloProfile200Response? = null
|
||||
private set
|
||||
@@ -72,7 +83,8 @@ class KiloBackendAppService(private val cs: CoroutineScope) : Disposable {
|
||||
|
||||
suspend fun connect() {
|
||||
mutex.withLock {
|
||||
if (state.value is ConnectionState.Connected || state.value is ConnectionState.Connecting) return
|
||||
val current = _appState.value
|
||||
if (current is KiloAppState.Ready || current is KiloAppState.Connecting || current is KiloAppState.Loading) return
|
||||
connection.connect()
|
||||
}
|
||||
}
|
||||
@@ -100,14 +112,10 @@ class KiloBackendAppService(private val cs: CoroutineScope) : Disposable {
|
||||
|
||||
// ── Internals ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Full reconnect triggered when the CLI process dies.
|
||||
* Serialized by the same mutex as user-initiated operations.
|
||||
*/
|
||||
private suspend fun reconnect() {
|
||||
mutex.withLock {
|
||||
val current = state.value
|
||||
if (current is ConnectionState.Connected || current is ConnectionState.Connecting) {
|
||||
val current = _appState.value
|
||||
if (current is KiloAppState.Ready || current is KiloAppState.Connecting || current is KiloAppState.Loading) {
|
||||
LOG.info("reconnect: already ${current::class.simpleName} — skipping")
|
||||
return
|
||||
}
|
||||
@@ -117,12 +125,13 @@ class KiloBackendAppService(private val cs: CoroutineScope) : Disposable {
|
||||
}
|
||||
|
||||
init {
|
||||
// Watch connection state — load global data on each (re)connect.
|
||||
cs.launch {
|
||||
connection.state.collect { next ->
|
||||
if (next is ConnectionState.Connected) {
|
||||
load()
|
||||
ensureRouter()
|
||||
when (next) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -130,76 +139,166 @@ class KiloBackendAppService(private val cs: CoroutineScope) : Disposable {
|
||||
|
||||
/**
|
||||
* Launch all project-independent data fetches in parallel.
|
||||
* Each fetch is independent — a failure in one does not block the others.
|
||||
*
|
||||
* Config and notifications are required — retried up to [MAX_RETRIES] times.
|
||||
* Profile is optional — 401 (not logged in) is fine.
|
||||
*
|
||||
* Progress is tracked via [LoadProgress] and emitted as [KiloAppState.Loading].
|
||||
* On success, transitions to [KiloAppState.Ready].
|
||||
* On failure of required data, transitions to [KiloAppState.Error].
|
||||
*/
|
||||
private fun load() {
|
||||
loader?.cancel()
|
||||
loader = cs.launch {
|
||||
LOG.info("Loading global data")
|
||||
coroutineScope {
|
||||
launch { loadProfile() }
|
||||
launch { loadConfig() }
|
||||
launch { loadNotifications() }
|
||||
val progress = AtomicReference(LoadProgress())
|
||||
_appState.value = KiloAppState.Loading(progress.get())
|
||||
|
||||
val errors = mutableListOf<LoadError>()
|
||||
|
||||
try {
|
||||
coroutineScope {
|
||||
launch {
|
||||
val result = fetchProfile()
|
||||
progress.updateAndGet { it.copy(profile = result) }
|
||||
.also { _appState.value = KiloAppState.Loading(it) }
|
||||
}
|
||||
launch {
|
||||
val result = fetchWithRetry("config") { fetchConfig() }
|
||||
if (result.value != null) {
|
||||
config = result.value
|
||||
progress.updateAndGet { it.copy(config = true) }
|
||||
.also { _appState.value = KiloAppState.Loading(it) }
|
||||
} else {
|
||||
val err = result.error!!
|
||||
synchronized(errors) { errors.add(err) }
|
||||
throw LoadFailure(err)
|
||||
}
|
||||
}
|
||||
launch {
|
||||
val result = fetchWithRetry("notifications") { fetchNotifications() }
|
||||
if (result.value != null) {
|
||||
notifications = result.value
|
||||
progress.updateAndGet { it.copy(notifications = true) }
|
||||
.also { _appState.value = KiloAppState.Loading(it) }
|
||||
} else {
|
||||
val err = result.error!!
|
||||
synchronized(errors) { errors.add(err) }
|
||||
throw LoadFailure(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_appState.value = KiloAppState.Ready(
|
||||
AppData(
|
||||
profile = profile,
|
||||
config = config!!,
|
||||
notifications = notifications,
|
||||
)
|
||||
)
|
||||
LOG.info("Global data loaded — app is Ready")
|
||||
ensureRouter()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
LOG.warn("Global data load failed: ${e.message}")
|
||||
_appState.value = KiloAppState.Error(
|
||||
message = "Failed to load required data",
|
||||
errors = synchronized(errors) { errors.toList() },
|
||||
)
|
||||
}
|
||||
LOG.info("Global data loaded")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadProfile() {
|
||||
val client = connection.api ?: return
|
||||
try {
|
||||
// ── Individual fetch functions ──────────────────────────────────
|
||||
|
||||
private suspend fun fetchProfile(): ProfileResult {
|
||||
val client = connection.api ?: return ProfileResult.NOT_LOGGED_IN
|
||||
return try {
|
||||
val response = client.kiloProfile()
|
||||
profile = response
|
||||
LOG.info("Profile: ${response.profile.email}")
|
||||
ProfileResult.LOADED
|
||||
} catch (e: Exception) {
|
||||
// 401 = not logged in to Kilo Gateway — expected, not an error
|
||||
LOG.info("Profile fetch skipped: ${e.message}")
|
||||
ProfileResult.NOT_LOGGED_IN
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadConfig() {
|
||||
val client = connection.api ?: return
|
||||
try {
|
||||
val response = client.globalConfigGet()
|
||||
config = response
|
||||
LOG.info("Global config loaded")
|
||||
private suspend fun fetchConfig(): FetchResult<Config> {
|
||||
val client = connection.api
|
||||
?: return FetchResult.fail("config", detail = "Not connected")
|
||||
return try {
|
||||
FetchResult.ok(client.globalConfigGet())
|
||||
} catch (e: Exception) {
|
||||
LOG.warn("Global config fetch failed", e)
|
||||
LOG.warn("Global config fetch failed: ${e.message}")
|
||||
FetchResult.fail("config", e)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadNotifications() {
|
||||
val client = connection.api ?: return
|
||||
try {
|
||||
val response = client.kiloNotifications()
|
||||
notifications = response
|
||||
LOG.info("Notifications: ${response.size} items")
|
||||
private suspend fun fetchNotifications(): FetchResult<List<KiloNotifications200ResponseInner>> {
|
||||
val client = connection.api
|
||||
?: return FetchResult.fail("notifications", detail = "Not connected")
|
||||
return try {
|
||||
FetchResult.ok(client.kiloNotifications())
|
||||
} catch (e: Exception) {
|
||||
LOG.warn("Notifications fetch failed", e)
|
||||
LOG.warn("Notifications fetch failed: ${e.message}")
|
||||
FetchResult.fail("notifications", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Route SSE events that require global data reloads.
|
||||
* Only one router runs at a time — restarted on reconnect.
|
||||
*/
|
||||
// ── Retry helper ────────────────────────────────────────────────
|
||||
|
||||
private suspend fun <T> fetchWithRetry(
|
||||
name: String,
|
||||
block: suspend () -> FetchResult<T>,
|
||||
): FetchResult<T> {
|
||||
var last: FetchResult<T> = FetchResult.fail(name, detail = "No attempts made")
|
||||
repeat(MAX_RETRIES) { attempt ->
|
||||
last = block()
|
||||
if (last.value != null) return last
|
||||
if (attempt < MAX_RETRIES - 1) {
|
||||
LOG.warn("$name: attempt ${attempt + 1}/$MAX_RETRIES failed — retrying in ${RETRY_DELAY_MS}ms")
|
||||
delay(RETRY_DELAY_MS)
|
||||
}
|
||||
}
|
||||
LOG.error("$name: all $MAX_RETRIES attempts failed")
|
||||
return last
|
||||
}
|
||||
|
||||
// ── SSE event routing ───────────────────────────────────────────
|
||||
|
||||
private fun ensureRouter() {
|
||||
if (router?.isActive == true) return
|
||||
router = cs.launch {
|
||||
connection.events.collect { event ->
|
||||
when (event.type) {
|
||||
"global.config.updated" -> launch { loadConfig() }
|
||||
"global.config.updated" -> 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cleanup ─────────────────────────────────────────────────────
|
||||
|
||||
private fun clear() {
|
||||
loader?.cancel()
|
||||
router?.cancel()
|
||||
profile = null
|
||||
config = null
|
||||
notifications = emptyList()
|
||||
_appState.value = KiloAppState.Disconnected
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
@@ -208,3 +307,45 @@ class KiloBackendAppService(private val cs: CoroutineScope) : Disposable {
|
||||
server.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a data fetch — either a value or an error with details.
|
||||
*/
|
||||
private data class FetchResult<T>(val value: T?, val error: LoadError?) {
|
||||
companion object {
|
||||
fun <T> ok(value: T) = FetchResult<T>(value, null)
|
||||
|
||||
fun <T> fail(resource: String, exception: Exception) = FetchResult<T>(
|
||||
value = null,
|
||||
error = LoadError(
|
||||
resource = resource,
|
||||
status = httpStatus(exception),
|
||||
detail = httpDetail(exception),
|
||||
),
|
||||
)
|
||||
|
||||
fun <T> fail(resource: String, detail: String) = FetchResult<T>(
|
||||
value = null,
|
||||
error = LoadError(resource = resource, detail = detail),
|
||||
)
|
||||
|
||||
private fun httpStatus(e: Exception): Int? =
|
||||
when (e) {
|
||||
is ClientException -> e.statusCode
|
||||
is ServerException -> e.statusCode
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun httpDetail(e: Exception): String? =
|
||||
when (e) {
|
||||
is ClientException -> "HTTP ${e.statusCode}: ${e.message}"
|
||||
is ServerException -> "HTTP ${e.statusCode}: ${e.message}"
|
||||
is java.net.ConnectException -> "Connection refused: ${e.message}"
|
||||
is java.net.SocketTimeoutException -> "Timeout: ${e.message}"
|
||||
else -> e.message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown when a required data fetch exhausts all retries. */
|
||||
private class LoadFailure(val error: LoadError) : Exception("Failed to load ${error.resource}")
|
||||
|
||||
+3
-3
@@ -36,9 +36,9 @@ class KiloBackendProjectService(
|
||||
val directory: String
|
||||
get() = project.basePath ?: ""
|
||||
|
||||
/** Connection state (delegates to app-level service). */
|
||||
val state: StateFlow<ConnectionState>
|
||||
get() = app.state
|
||||
/** App lifecycle state (delegates to app-level service). */
|
||||
val state: StateFlow<KiloAppState>
|
||||
get() = app.appState
|
||||
|
||||
/** Ensure the CLI backend is running and connected. */
|
||||
suspend fun connect() = app.connect()
|
||||
|
||||
+40
-10
@@ -2,12 +2,18 @@
|
||||
|
||||
package ai.kilocode.backend.rpc
|
||||
|
||||
import ai.kilocode.backend.ConnectionState
|
||||
import ai.kilocode.backend.KiloAppState
|
||||
import ai.kilocode.backend.KiloBackendAppService
|
||||
import ai.kilocode.backend.LoadError
|
||||
import ai.kilocode.backend.LoadProgress
|
||||
import ai.kilocode.backend.ProfileResult
|
||||
import ai.kilocode.rpc.KiloAppRpcApi
|
||||
import ai.kilocode.rpc.dto.ConnectionStateDto
|
||||
import ai.kilocode.rpc.dto.ConnectionStatusDto
|
||||
import ai.kilocode.rpc.dto.HealthDto
|
||||
import ai.kilocode.rpc.dto.KiloAppStateDto
|
||||
import ai.kilocode.rpc.dto.KiloAppStatusDto
|
||||
import ai.kilocode.rpc.dto.LoadErrorDto
|
||||
import ai.kilocode.rpc.dto.LoadProgressDto
|
||||
import ai.kilocode.rpc.dto.ProfileStatusDto
|
||||
import com.intellij.openapi.components.service
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
@@ -25,8 +31,8 @@ class KiloAppRpcApiImpl : KiloAppRpcApi {
|
||||
|
||||
override suspend fun connect() = app.connect()
|
||||
|
||||
override suspend fun state(): Flow<ConnectionStateDto> =
|
||||
app.state.map(::dto).distinctUntilChanged()
|
||||
override suspend fun state(): Flow<KiloAppStateDto> =
|
||||
app.appState.map(::dto).distinctUntilChanged()
|
||||
|
||||
override suspend fun health(): HealthDto = app.health()
|
||||
|
||||
@@ -34,11 +40,35 @@ class KiloAppRpcApiImpl : KiloAppRpcApi {
|
||||
|
||||
override suspend fun reinstall() = app.reinstall()
|
||||
|
||||
private fun dto(state: ConnectionState): ConnectionStateDto =
|
||||
private fun dto(state: KiloAppState): KiloAppStateDto =
|
||||
when (state) {
|
||||
ConnectionState.Disconnected -> ConnectionStateDto(ConnectionStatusDto.DISCONNECTED)
|
||||
ConnectionState.Connecting -> ConnectionStateDto(ConnectionStatusDto.CONNECTING)
|
||||
is ConnectionState.Connected -> ConnectionStateDto(ConnectionStatusDto.CONNECTED)
|
||||
is ConnectionState.Error -> ConnectionStateDto(ConnectionStatusDto.ERROR, state.message)
|
||||
KiloAppState.Disconnected -> KiloAppStateDto(KiloAppStatusDto.DISCONNECTED)
|
||||
KiloAppState.Connecting -> KiloAppStateDto(KiloAppStatusDto.CONNECTING)
|
||||
is KiloAppState.Loading -> KiloAppStateDto(
|
||||
status = KiloAppStatusDto.LOADING,
|
||||
progress = progress(state.progress),
|
||||
)
|
||||
is KiloAppState.Ready -> KiloAppStateDto(KiloAppStatusDto.READY)
|
||||
is KiloAppState.Error -> KiloAppStateDto(
|
||||
status = KiloAppStatusDto.ERROR,
|
||||
error = state.message,
|
||||
errors = state.errors.map(::error),
|
||||
)
|
||||
}
|
||||
|
||||
private fun progress(p: LoadProgress) = LoadProgressDto(
|
||||
config = p.config,
|
||||
notifications = p.notifications,
|
||||
profile = when (p.profile) {
|
||||
ProfileResult.PENDING -> ProfileStatusDto.PENDING
|
||||
ProfileResult.LOADED -> ProfileStatusDto.LOADED
|
||||
ProfileResult.NOT_LOGGED_IN -> ProfileStatusDto.NOT_LOGGED_IN
|
||||
},
|
||||
)
|
||||
|
||||
private fun error(e: LoadError) = LoadErrorDto(
|
||||
resource = e.resource,
|
||||
status = e.status,
|
||||
detail = e.detail,
|
||||
)
|
||||
}
|
||||
|
||||
+9
-9
@@ -3,9 +3,9 @@
|
||||
package ai.kilocode.client
|
||||
|
||||
import ai.kilocode.rpc.KiloAppRpcApi
|
||||
import ai.kilocode.rpc.dto.ConnectionStateDto
|
||||
import ai.kilocode.rpc.dto.ConnectionStatusDto
|
||||
import ai.kilocode.rpc.dto.HealthDto
|
||||
import ai.kilocode.rpc.dto.KiloAppStateDto
|
||||
import ai.kilocode.rpc.dto.KiloAppStatusDto
|
||||
import com.intellij.openapi.components.Service
|
||||
import com.intellij.openapi.diagnostic.Logger
|
||||
import fleet.rpc.client.durable
|
||||
@@ -25,13 +25,13 @@ import kotlinx.coroutines.launch
|
||||
* are app-scoped — no project context is needed.
|
||||
*
|
||||
* Callers of [watch] are responsible for scheduling UI updates on
|
||||
* the EDT and converting [ConnectionStateDto] to display text.
|
||||
* the EDT and converting [KiloAppStateDto] to display text.
|
||||
*/
|
||||
@Service(Service.Level.APP)
|
||||
class KiloAppService(private val cs: CoroutineScope) {
|
||||
companion object {
|
||||
private val LOG = Logger.getInstance(KiloAppService::class.java)
|
||||
private val init = ConnectionStateDto(ConnectionStatusDto.DISCONNECTED)
|
||||
private val init = KiloAppStateDto(KiloAppStatusDto.DISCONNECTED)
|
||||
}
|
||||
|
||||
private val started = AtomicBoolean(false)
|
||||
@@ -41,7 +41,7 @@ class KiloAppService(private val cs: CoroutineScope) {
|
||||
var version: String? = null
|
||||
private set
|
||||
|
||||
val state: StateFlow<ConnectionStateDto> = flow {
|
||||
val state: StateFlow<KiloAppStateDto> = flow {
|
||||
durable {
|
||||
KiloAppRpcApi.getInstance()
|
||||
.state()
|
||||
@@ -111,15 +111,15 @@ class KiloAppService(private val cs: CoroutineScope) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect connection state changes and invoke [fn] for each update.
|
||||
* Collect app state changes and invoke [fn] for each update.
|
||||
*
|
||||
* The callback receives raw [ConnectionStateDto] — the caller is
|
||||
* The callback receives raw [KiloAppStateDto] — the caller is
|
||||
* responsible for converting to display text and scheduling on the EDT.
|
||||
*/
|
||||
fun watch(fn: (ConnectionStateDto) -> Unit): Job {
|
||||
fun watch(fn: (KiloAppStateDto) -> Unit): Job {
|
||||
return cs.launch {
|
||||
state.collect { next ->
|
||||
if (next.status == ConnectionStatusDto.CONNECTED) fetchVersionAsync()
|
||||
if (next.status == KiloAppStatusDto.READY) fetchVersionAsync()
|
||||
fn(next)
|
||||
}
|
||||
}
|
||||
|
||||
+87
-10
@@ -1,8 +1,11 @@
|
||||
package ai.kilocode.client
|
||||
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.rpc.dto.ConnectionStateDto
|
||||
import ai.kilocode.rpc.dto.ConnectionStatusDto
|
||||
import ai.kilocode.rpc.dto.KiloAppStateDto
|
||||
import ai.kilocode.rpc.dto.KiloAppStatusDto
|
||||
import ai.kilocode.rpc.dto.LoadErrorDto
|
||||
import ai.kilocode.rpc.dto.LoadProgressDto
|
||||
import ai.kilocode.rpc.dto.ProfileStatusDto
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.actionSystem.ActionManager
|
||||
import com.intellij.openapi.components.service
|
||||
@@ -34,19 +37,32 @@ class KiloToolWindowFactory : ToolWindowFactory {
|
||||
alignmentX = JPanel.CENTER_ALIGNMENT
|
||||
}
|
||||
|
||||
val text = JBLabel(KiloBundle.message("toolwindow.status.disconnected"), SwingConstants.CENTER).apply {
|
||||
val status = JBLabel(
|
||||
KiloBundle.message("toolwindow.status.disconnected"),
|
||||
SwingConstants.CENTER,
|
||||
).apply {
|
||||
alignmentX = JPanel.CENTER_ALIGNMENT
|
||||
font = JBUI.Fonts.label(13f)
|
||||
foreground = UIUtil.getContextHelpForeground()
|
||||
setAllowAutoWrapping(true)
|
||||
}
|
||||
|
||||
val detail = JBLabel("", SwingConstants.CENTER).apply {
|
||||
alignmentX = JPanel.CENTER_ALIGNMENT
|
||||
font = JBUI.Fonts.smallFont()
|
||||
foreground = UIUtil.getContextHelpForeground()
|
||||
setAllowAutoWrapping(true)
|
||||
setCopyable(true)
|
||||
}
|
||||
|
||||
val body = JPanel().apply {
|
||||
layout = BoxLayout(this, BoxLayout.Y_AXIS)
|
||||
isOpaque = false
|
||||
add(icon)
|
||||
add(Box.createVerticalStrut(JBUI.scale(16)))
|
||||
add(text)
|
||||
add(status)
|
||||
add(Box.createVerticalStrut(JBUI.scale(6)))
|
||||
add(detail)
|
||||
}
|
||||
|
||||
val panel = JPanel(GridBagLayout()).apply {
|
||||
@@ -58,7 +74,9 @@ class KiloToolWindowFactory : ToolWindowFactory {
|
||||
val ui = Disposer.newDisposable()
|
||||
val job = svc.watch { state ->
|
||||
mgr.invokeLater {
|
||||
text.text = format(state)
|
||||
status.text = title(state)
|
||||
detail.text = details(state)
|
||||
detail.isVisible = detail.text.isNotEmpty()
|
||||
}
|
||||
}
|
||||
Disposer.register(ui, Disposable { job.cancel() })
|
||||
@@ -70,14 +88,73 @@ class KiloToolWindowFactory : ToolWindowFactory {
|
||||
svc.connect()
|
||||
}
|
||||
|
||||
private fun format(state: ConnectionStateDto): String =
|
||||
private fun title(state: KiloAppStateDto): String =
|
||||
when (state.status) {
|
||||
ConnectionStatusDto.DISCONNECTED -> KiloBundle.message("toolwindow.status.disconnected")
|
||||
ConnectionStatusDto.CONNECTING -> KiloBundle.message("toolwindow.status.connecting")
|
||||
ConnectionStatusDto.CONNECTED -> KiloBundle.message("toolwindow.status.connected")
|
||||
ConnectionStatusDto.ERROR -> KiloBundle.message(
|
||||
KiloAppStatusDto.DISCONNECTED -> KiloBundle.message("toolwindow.status.disconnected")
|
||||
KiloAppStatusDto.CONNECTING -> KiloBundle.message("toolwindow.status.connecting")
|
||||
KiloAppStatusDto.LOADING -> KiloBundle.message("toolwindow.status.loading")
|
||||
KiloAppStatusDto.READY -> KiloBundle.message("toolwindow.status.connected")
|
||||
KiloAppStatusDto.ERROR -> KiloBundle.message(
|
||||
"toolwindow.status.error",
|
||||
state.error ?: KiloBundle.message("toolwindow.error.unknown"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun details(state: KiloAppStateDto): String =
|
||||
when (state.status) {
|
||||
KiloAppStatusDto.LOADING -> progress(state.progress)
|
||||
KiloAppStatusDto.READY -> ready(state)
|
||||
KiloAppStatusDto.ERROR -> errors(state)
|
||||
else -> ""
|
||||
}
|
||||
|
||||
private fun progress(p: LoadProgressDto?): String {
|
||||
if (p == null) return ""
|
||||
val lines = mutableListOf<String>()
|
||||
lines.add(item("Config", p.config))
|
||||
lines.add(item("Notifications", p.notifications))
|
||||
lines.add(profile(p.profile))
|
||||
return "<html>${lines.joinToString("<br>")}</html>"
|
||||
}
|
||||
|
||||
private fun item(name: String, loaded: Boolean): String =
|
||||
if (loaded) "$CHECK $name" else "$DOTS $name"
|
||||
|
||||
private fun profile(status: ProfileStatusDto): String =
|
||||
when (status) {
|
||||
ProfileStatusDto.PENDING -> "$DOTS Profile"
|
||||
ProfileStatusDto.LOADED -> "$CHECK Profile"
|
||||
ProfileStatusDto.NOT_LOGGED_IN -> "$DASH Profile (not logged in)"
|
||||
}
|
||||
|
||||
private fun ready(state: KiloAppStateDto): String {
|
||||
val svc = service<KiloAppService>()
|
||||
val ver = svc.version
|
||||
val lines = mutableListOf<String>()
|
||||
if (ver != null) lines.add("CLI: $ver")
|
||||
val p = state.progress
|
||||
if (p != null && p.profile == ProfileStatusDto.NOT_LOGGED_IN) {
|
||||
lines.add("Profile: not logged in")
|
||||
}
|
||||
return if (lines.isEmpty()) "" else "<html>${lines.joinToString("<br>")}</html>"
|
||||
}
|
||||
|
||||
private fun errors(state: KiloAppStateDto): String {
|
||||
if (state.errors.isEmpty()) return ""
|
||||
val lines = state.errors.map(::formatError)
|
||||
return "<html>Failed to load:<br>${lines.joinToString("<br>")}</html>"
|
||||
}
|
||||
|
||||
private fun formatError(err: LoadErrorDto): String {
|
||||
val suffix = err.detail ?: err.status?.let { "HTTP $it" } ?: ""
|
||||
return if (suffix.isEmpty()) "$CROSS ${err.resource}"
|
||||
else "$CROSS ${err.resource}: $suffix"
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CHECK = "\u2713"
|
||||
private const val CROSS = "\u2717"
|
||||
private const val DOTS = "\u2026"
|
||||
private const val DASH = "\u2013"
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
package ai.kilocode.client.actions
|
||||
|
||||
import ai.kilocode.client.KiloAppService
|
||||
import ai.kilocode.rpc.dto.ConnectionStatusDto
|
||||
import ai.kilocode.rpc.dto.KiloAppStatusDto
|
||||
import com.intellij.openapi.actionSystem.AnAction
|
||||
import com.intellij.openapi.actionSystem.AnActionEvent
|
||||
import com.intellij.openapi.components.service
|
||||
@@ -12,7 +12,7 @@ class ReinstallKiloAction : AnAction() {
|
||||
}
|
||||
|
||||
override fun update(e: AnActionEvent) {
|
||||
val state = service<KiloAppService>().state.value
|
||||
e.presentation.isEnabled = state.status != ConnectionStatusDto.CONNECTING
|
||||
val status = service<KiloAppService>().state.value.status
|
||||
e.presentation.isEnabled = status != KiloAppStatusDto.CONNECTING && status != KiloAppStatusDto.LOADING
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
package ai.kilocode.client.actions
|
||||
|
||||
import ai.kilocode.client.KiloAppService
|
||||
import ai.kilocode.rpc.dto.ConnectionStatusDto
|
||||
import ai.kilocode.rpc.dto.KiloAppStatusDto
|
||||
import com.intellij.openapi.actionSystem.AnAction
|
||||
import com.intellij.openapi.actionSystem.AnActionEvent
|
||||
import com.intellij.openapi.components.service
|
||||
@@ -12,7 +12,7 @@ class RestartKiloAction : AnAction() {
|
||||
}
|
||||
|
||||
override fun update(e: AnActionEvent) {
|
||||
val state = service<KiloAppService>().state.value
|
||||
e.presentation.isEnabled = state.status != ConnectionStatusDto.CONNECTING
|
||||
val status = service<KiloAppService>().state.value.status
|
||||
e.presentation.isEnabled = status != KiloAppStatusDto.CONNECTING && status != KiloAppStatusDto.LOADING
|
||||
}
|
||||
}
|
||||
|
||||
+6
-5
@@ -2,7 +2,7 @@ package ai.kilocode.client.actions
|
||||
|
||||
import ai.kilocode.client.KiloAppService
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.rpc.dto.ConnectionStatusDto
|
||||
import ai.kilocode.rpc.dto.KiloAppStatusDto
|
||||
import com.intellij.openapi.actionSystem.AnAction
|
||||
import com.intellij.openapi.actionSystem.AnActionEvent
|
||||
import com.intellij.openapi.components.service
|
||||
@@ -19,10 +19,11 @@ class StatusInfoAction : AnAction() {
|
||||
override fun update(e: AnActionEvent) {
|
||||
val svc = service<KiloAppService>()
|
||||
val status = when (svc.state.value.status) {
|
||||
ConnectionStatusDto.CONNECTED -> KiloBundle.message("toolwindow.status.connected.short")
|
||||
ConnectionStatusDto.CONNECTING -> KiloBundle.message("toolwindow.status.connecting.short")
|
||||
ConnectionStatusDto.DISCONNECTED -> KiloBundle.message("toolwindow.status.disconnected.short")
|
||||
ConnectionStatusDto.ERROR -> KiloBundle.message("toolwindow.status.error.short")
|
||||
KiloAppStatusDto.READY -> KiloBundle.message("toolwindow.status.connected.short")
|
||||
KiloAppStatusDto.CONNECTING -> KiloBundle.message("toolwindow.status.connecting.short")
|
||||
KiloAppStatusDto.LOADING -> KiloBundle.message("toolwindow.status.loading.short")
|
||||
KiloAppStatusDto.DISCONNECTED -> KiloBundle.message("toolwindow.status.disconnected.short")
|
||||
KiloAppStatusDto.ERROR -> KiloBundle.message("toolwindow.status.error.short")
|
||||
}
|
||||
val ver = svc.version?.let { " · $it" } ?: ""
|
||||
e.presentation.text = "$status$ver"
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
toolwindow.status.disconnected=Status: Disconnected
|
||||
toolwindow.status.connecting=Status: Connecting...
|
||||
toolwindow.status.loading=Status: Loading...
|
||||
toolwindow.status.connected=Status: Connected
|
||||
toolwindow.status.error=Status: Error - {0}
|
||||
toolwindow.error.unknown=Unknown error
|
||||
|
||||
toolwindow.status.connected.short=Connected
|
||||
toolwindow.status.connecting.short=Connecting\u2026
|
||||
toolwindow.status.loading.short=Loading\u2026
|
||||
toolwindow.status.disconnected.short=Disconnected
|
||||
toolwindow.status.error.short=Error
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package ai.kilocode.rpc
|
||||
|
||||
import ai.kilocode.rpc.dto.ConnectionStateDto
|
||||
import ai.kilocode.rpc.dto.HealthDto
|
||||
import ai.kilocode.rpc.dto.KiloAppStateDto
|
||||
import com.intellij.platform.rpc.RemoteApiProviderService
|
||||
import fleet.rpc.RemoteApi
|
||||
import fleet.rpc.Rpc
|
||||
@@ -25,8 +25,8 @@ interface KiloAppRpcApi : RemoteApi<Unit> {
|
||||
/** Ensure the CLI backend is running and connected. */
|
||||
suspend fun connect()
|
||||
|
||||
/** Observe connection state changes. */
|
||||
suspend fun state(): Flow<ConnectionStateDto>
|
||||
/** Observe app lifecycle state changes. */
|
||||
suspend fun state(): Flow<KiloAppStateDto>
|
||||
|
||||
/** One-shot health check against /global/health. */
|
||||
suspend fun health(): HealthDto
|
||||
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
package ai.kilocode.rpc.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
enum class ConnectionStatusDto {
|
||||
DISCONNECTED,
|
||||
CONNECTING,
|
||||
CONNECTED,
|
||||
ERROR,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ConnectionStateDto(
|
||||
val status: ConnectionStatusDto,
|
||||
val error: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
package ai.kilocode.rpc.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
enum class KiloAppStatusDto {
|
||||
DISCONNECTED,
|
||||
CONNECTING,
|
||||
LOADING,
|
||||
READY,
|
||||
ERROR,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class ProfileStatusDto {
|
||||
PENDING,
|
||||
LOADED,
|
||||
NOT_LOGGED_IN,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class LoadProgressDto(
|
||||
val config: Boolean = false,
|
||||
val notifications: Boolean = false,
|
||||
val profile: ProfileStatusDto = ProfileStatusDto.PENDING,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LoadErrorDto(
|
||||
val resource: String,
|
||||
val status: Int? = null,
|
||||
val detail: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class KiloAppStateDto(
|
||||
val status: KiloAppStatusDto,
|
||||
val error: String? = null,
|
||||
val errors: List<LoadErrorDto> = emptyList(),
|
||||
val progress: LoadProgressDto? = null,
|
||||
)
|
||||
Reference in New Issue
Block a user