diff --git a/.changeset/chilly-dancers-build.md b/.changeset/chilly-dancers-build.md new file mode 100644 index 00000000000..3f8b568bc5d --- /dev/null +++ b/.changeset/chilly-dancers-build.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Add a JetBrains tool window action for starting new sessions and let recent sessions reopen in the side panel without duplicate views. diff --git a/.changeset/fuzzy-berries-press.md b/.changeset/fuzzy-berries-press.md new file mode 100644 index 00000000000..0c48bad5166 --- /dev/null +++ b/.changeset/fuzzy-berries-press.md @@ -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. diff --git a/.changeset/green-planes-reply.md b/.changeset/green-planes-reply.md new file mode 100644 index 00000000000..f9b3517d9c4 --- /dev/null +++ b/.changeset/green-planes-reply.md @@ -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. diff --git a/.changeset/slow-terms-dance.md b/.changeset/slow-terms-dance.md new file mode 100644 index 00000000000..777d5bec8e7 --- /dev/null +++ b/.changeset/slow-terms-dance.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Keep the JetBrains session connection status above the prompt without shifting the chat layout. diff --git a/.changeset/soft-garlic-care.md b/.changeset/soft-garlic-care.md new file mode 100644 index 00000000000..ee6f55a6d61 --- /dev/null +++ b/.changeset/soft-garlic-care.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Refine JetBrains session loading UI and settings actions. diff --git a/.changeset/tidy-otters-wait.md b/.changeset/tidy-otters-wait.md new file mode 100644 index 00000000000..151fcfe79af --- /dev/null +++ b/.changeset/tidy-otters-wait.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Avoid flickering transient loading and connection error states in JetBrains sessions. diff --git a/.editorconfig b/.editorconfig index aada95f26a8..35ebc0b32db 100644 --- a/.editorconfig +++ b/.editorconfig @@ -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 diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloAppState.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloAppState.kt index 220c11c9b96..7bb9c069e10 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloAppState.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloAppState.kt @@ -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]. @@ -48,4 +54,5 @@ data class AppData( val profile: KiloProfile200Response?, val config: Config, val notifications: List, + val warnings: List = emptyList(), ) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt index 9f36144d284..7d806d678d9 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt @@ -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 @@ -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 @@ -107,6 +109,9 @@ class KiloBackendAppService private constructor( @Volatile var notifications: List = emptyList() private set + @Volatile var warnings: List = emptyList() + private set + suspend fun connect() { mutex.withLock { val current = _appState.value @@ -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") @@ -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(), + ) } } } @@ -186,6 +232,7 @@ class KiloBackendAppService private constructor( var cfg: Config? = null var prof: KiloProfile200Response? = null var notifs: List = emptyList() + var warns: List = emptyList() try { coroutineScope { @@ -229,6 +276,9 @@ class KiloBackendAppService private constructor( throw LoadFailure(err) } } + launch { + warns = fetchWarnings() + } } ensureActive() @@ -237,12 +287,13 @@ class KiloBackendAppService private constructor( notifications = notifs 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( + workspaces.start(connection.api!!, connection.apiClient!!, connection.port, connection.events) + setAppReady( AppData( profile = prof, config = cfg!!, notifications = notifs, + warnings = warns, ) ) log.info("Application started — config, profile, notifications loaded") @@ -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(), ) @@ -321,6 +372,85 @@ class KiloBackendAppService private constructor( } } + private suspend fun fetchWarnings(): List { + 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 + val connection = connection.state.value as? ConnectionState.Connected ?: return + val cfg = fetchConfig().value ?: return + val warns = fetchWarnings() + val state = _appState.value + if (state !is KiloAppState.Ready || state.data !== current.data) return + if (this.connection.state.value != connection) return + 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) { + 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) { + 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] @@ -380,17 +510,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" -> { @@ -420,6 +541,7 @@ class KiloBackendAppService private constructor( profile = null config = null notifications = emptyList() + warnings = emptyList() _appState.value = KiloAppState.Disconnected } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt index e64249c3db1..d9a891cbfce 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt @@ -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) @@ -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 } @@ -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() } } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt index ebe10cbf3bc..6094437a0f9 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt @@ -4,6 +4,7 @@ import ai.kilocode.backend.cli.KiloCliDataParser import ai.kilocode.log.ChatLogSummary import ai.kilocode.log.KiloLog import ai.kilocode.jetbrains.api.client.DefaultApi +import ai.kilocode.jetbrains.api.model.GlobalSession import ai.kilocode.jetbrains.api.model.SessionStatus import ai.kilocode.rpc.dto.SessionDto import ai.kilocode.rpc.dto.SessionListDto @@ -96,6 +97,21 @@ class KiloBackendSessionManager( return SessionListDto(mapped, relevant) } + fun recent(dir: String, limit: Int): SessionListDto { + seed(dir) + val raw = requireClient().experimentalSessionList( + directory = dir, + worktrees = true, + roots = true, + limit = limit.toDouble(), + archived = false, + ) + val mapped = raw.map(::dto) + val ids = mapped.map { it.id }.toSet() + val relevant = _statuses.value.filterKeys { it in ids } + return SessionListDto(mapped, relevant) + } + /** * Create a new session in the given directory. * @@ -182,8 +198,32 @@ class KiloBackendSessionManager( }, ) + private fun dto(s: GlobalSession) = SessionDto( + id = s.id, + projectID = s.projectID, + directory = s.directory, + parentID = s.parentID, + title = s.title, + version = s.version, + time = SessionTimeDto( + created = s.time.created, + updated = s.time.updated, + archived = s.time.archived, + ), + summary = s.summary?.let { + SessionSummaryDto( + additions = it.additions.toInt(), + deletions = it.deletions.toInt(), + files = it.files.toInt(), + ) + }, + ) + private fun statusDto(s: SessionStatus) = SessionStatusDto( type = s.type.value, message = s.message.ifBlank { null }, + attempt = s.attempt.toInt(), + next = s.next.toLong(), + requestID = s.requestID.ifBlank { null }, ) } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt index e11f315a3fd..86420b6fc4a 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt @@ -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 @@ -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() @@ -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, @@ -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, + ) } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt index 89d8935c2b8..26b98a02a9f 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt @@ -51,6 +51,9 @@ class KiloSessionRpcApiImpl : KiloSessionRpcApi { override suspend fun list(directory: String): SessionListDto = workspaces.get(directory).sessions() + override suspend fun recent(directory: String, limit: Int): SessionListDto = + sessions.recent(directory, limit) + override suspend fun create(directory: String): SessionDto { LOG.info("create session: directory=$directory") return workspaces.get(directory).createSession() diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt index 171a2e47d78..f8159030df2 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt @@ -4,6 +4,7 @@ package ai.kilocode.backend.rpc import ai.kilocode.backend.app.KiloAppState import ai.kilocode.backend.app.KiloBackendAppService +import ai.kilocode.backend.app.LoadError import ai.kilocode.backend.workspace.AgentData import ai.kilocode.backend.workspace.AgentInfo import ai.kilocode.backend.workspace.CommandInfo @@ -21,6 +22,7 @@ import ai.kilocode.rpc.dto.CommandDto import ai.kilocode.rpc.dto.KiloWorkspaceLoadProgressDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import ai.kilocode.rpc.dto.LoadErrorDto import ai.kilocode.rpc.dto.ModelDto import ai.kilocode.rpc.dto.ProviderDto import ai.kilocode.rpc.dto.ProvidersDto @@ -99,9 +101,16 @@ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi { is KiloWorkspaceState.Error -> KiloWorkspaceStateDto( status = KiloWorkspaceStatusDto.ERROR, error = state.message, + errors = state.errors.map(::error), ) } + private fun error(e: LoadError) = LoadErrorDto( + resource = e.resource, + status = e.status, + detail = e.detail, + ) + private fun progress(p: KiloWorkspaceLoadProgress) = KiloWorkspaceLoadProgressDto( providers = p.providers, agents = p.agents, diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt index f46f6715c9e..acbd32856a3 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt @@ -1,12 +1,20 @@ package ai.kilocode.backend.workspace import ai.kilocode.backend.app.KiloBackendSessionManager +import ai.kilocode.backend.app.LoadError import ai.kilocode.backend.app.SseEvent import ai.kilocode.log.KiloLog import ai.kilocode.jetbrains.api.client.DefaultApi import ai.kilocode.jetbrains.api.model.Agent import ai.kilocode.rpc.dto.SessionDto import ai.kilocode.rpc.dto.SessionListDto +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -18,6 +26,8 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import okhttp3.OkHttpClient +import okhttp3.Request import java.util.concurrent.atomic.AtomicReference /** @@ -36,6 +46,8 @@ class KiloBackendWorkspace( val directory: String, private val cs: CoroutineScope, private val api: DefaultApi, + private val http: OkHttpClient, + private val port: Int, private val events: SharedFlow, private val sessions: KiloBackendSessionManager, private val log: KiloLog, @@ -43,6 +55,7 @@ class KiloBackendWorkspace( companion object { private const val MAX_RETRIES = 3 private const val RETRY_DELAY_MS = 1000L + private val json = Json { ignoreUnknownKeys = true } } private val _state = MutableStateFlow(KiloWorkspaceState.Pending) @@ -66,52 +79,56 @@ class KiloBackendWorkspace( var ag: AgentData? = null var cmd: List? = null var sk: List? = null - val errors = mutableListOf() + val errors = mutableListOf() try { coroutineScope { launch { val result = fetchWithRetry("providers") { fetchProviders() } - if (result != null) { - prov = result + if (result.value != null) { + prov = result.value progress.updateAndGet { it.copy(providers = true) } .also { _state.value = KiloWorkspaceState.Loading(it) } } else { - synchronized(errors) { errors.add("providers") } - throw LoadFailure("providers") + val err = result.error ?: LoadError(resource = "providers") + synchronized(errors) { errors.add(err) } + throw LoadFailure(err) } } launch { val result = fetchWithRetry("agents") { fetchAgents() } - if (result != null) { - ag = result + if (result.value != null) { + ag = result.value progress.updateAndGet { it.copy(agents = true) } .also { _state.value = KiloWorkspaceState.Loading(it) } } else { - synchronized(errors) { errors.add("agents") } - throw LoadFailure("agents") + val err = result.error ?: LoadError(resource = "agents") + synchronized(errors) { errors.add(err) } + throw LoadFailure(err) } } launch { val result = fetchWithRetry("commands") { fetchCommands() } - if (result != null) { - cmd = result + if (result.value != null) { + cmd = result.value progress.updateAndGet { it.copy(commands = true) } .also { _state.value = KiloWorkspaceState.Loading(it) } } else { - synchronized(errors) { errors.add("commands") } - throw LoadFailure("commands") + val err = result.error ?: LoadError(resource = "commands") + synchronized(errors) { errors.add(err) } + throw LoadFailure(err) } } launch { val result = fetchWithRetry("skills") { fetchSkills() } - if (result != null) { - sk = result + if (result.value != null) { + sk = result.value progress.updateAndGet { it.copy(skills = true) } .also { _state.value = KiloWorkspaceState.Loading(it) } } else { - synchronized(errors) { errors.add("skills") } - throw LoadFailure("skills") + val err = result.error ?: LoadError(resource = "skills") + synchronized(errors) { errors.add(err) } + throw LoadFailure(err) } } } @@ -129,9 +146,9 @@ class KiloBackendWorkspace( throw e } catch (e: Exception) { log.warn("Workspace data load failed for $directory: ${e.message}") - _state.value = KiloWorkspaceState.Error( - "Failed to load: ${synchronized(errors) { errors.joinToString() }}" - ) + val items = synchronized(errors) { errors.toList() } + val names = items.joinToString { it.resource } + setWorkspaceError("Failed to load: $names", items) } } } @@ -191,79 +208,56 @@ class KiloBackendWorkspace( // ------ fetch methods ------ - private fun fetchProviders(): ProviderData? = + private fun fetchProviders(): FetchResult = try { - val response = api.providerList(directory = directory) - ProviderData( - providers = response.all.map { p -> - ProviderInfo( - id = p.id, - name = p.name, - source = p.source.value, - models = p.models.mapValues { (_, m) -> - ModelInfo( - id = m.id, - name = m.name, - attachment = m.capabilities.attachment, - reasoning = m.capabilities.reasoning, - temperature = m.capabilities.temperature, - toolCall = m.capabilities.toolcall, - free = m.isFree ?: false, - status = m.status.value, - ) - }, - ) - }, - connected = response.connected, - defaults = response.default, - ) + FetchResult.ok(parseProviders(fetch("/provider?directory=${encode(directory)}"))) } catch (e: Exception) { log.warn("Providers fetch failed: ${e.message}", e) - null + FetchResult.fail("providers", e) } - private fun fetchAgents(): AgentData? = + private fun fetchAgents(): FetchResult = try { val response = api.appAgents(directory = directory) val mapped = response.map(::mapAgent) val visible = response.filter { it.mode != Agent.Mode.SUBAGENT && it.hidden != true } - AgentData( + FetchResult.ok(AgentData( agents = visible.map(::mapAgent), all = mapped, default = visible.firstOrNull()?.name ?: "code", - ) + )) } catch (e: Exception) { log.warn("Agents fetch failed: ${e.message}", e) - null + FetchResult.fail("agents", e) } - private fun fetchCommands(): List? = + private fun fetchCommands(): FetchResult> = try { - api.commandList(directory = directory).map { c -> + FetchResult.ok(api.commandList(directory = directory).map { c -> CommandInfo( name = c.name, description = c.description, source = c.source?.value, hints = c.hints, ) - } + }) } catch (e: Exception) { log.warn("Commands fetch failed: ${e.message}", e) - null + FetchResult.fail("commands", e) } - private fun fetchSkills(): List? = + private fun fetchSkills(): FetchResult> = try { - api.appSkills(directory = directory).map { s -> + FetchResult.ok(api.appSkills(directory = directory).map { s -> SkillInfo( name = s.name, description = s.description, location = s.location, ) - } + }) } catch (e: Exception) { log.warn("Skills fetch failed: ${e.message}", e) - null + FetchResult.fail("skills", e) } // ------ helpers ------ @@ -279,21 +273,79 @@ class KiloBackendWorkspace( deprecated = a.deprecated, ) + private fun parseProviders(raw: String): ProviderData { + val obj = json.parseToJsonElement(raw).jsonObject + return ProviderData( + providers = obj["all"]?.jsonArray?.map { provider(it.jsonObject) } ?: emptyList(), + connected = obj["connected"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList(), + defaults = obj["default"]?.jsonObject?.mapValues { (_, value) -> value.jsonPrimitive.content } ?: emptyMap(), + ) + } + + private fun provider(obj: JsonObject) = ProviderInfo( + id = obj.str("id") ?: "", + name = obj.str("name") ?: "", + source = obj.str("source"), + models = obj["models"]?.jsonObject?.mapValues { (id, value) -> model(id, value.jsonObject) } ?: emptyMap(), + ) + + private fun model(id: String, obj: JsonObject): ModelInfo { + val cap = obj["capabilities"]?.jsonObject + return ModelInfo( + id = obj.str("id") ?: id, + name = obj.str("name") ?: id, + attachment = cap.bool("attachment"), + reasoning = cap.bool("reasoning"), + temperature = cap.bool("temperature"), + toolCall = cap.bool("toolcall"), + free = obj.bool("isFree"), + status = obj.str("status"), + ) + } + + private fun fetch(path: String): String { + val request = Request.Builder().url("http://localhost:$port$path").get().build() + http.newCall(request).execute().use { response -> + val raw = response.body?.string().orEmpty() + if (!response.isSuccessful) throw RuntimeException("HTTP ${response.code}: $raw") + return raw + } + } + private suspend fun fetchWithRetry( name: String, - block: () -> T?, - ): T? { + block: () -> FetchResult, + ): FetchResult { + var last = FetchResult.fail(name) repeat(MAX_RETRIES) { attempt -> val result = block() - if (result != null) return result + if (result.value != null) return result + last = result 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 null + return last } - private class LoadFailure(resource: String) : Exception("Failed to load $resource") + private fun setWorkspaceError(message: String, errors: List) { + _state.value = KiloWorkspaceState.Error(message, errors) + log.warn("Workspace error [$directory]: $message") + } + + private data class FetchResult(val value: T?, val error: LoadError?) { + companion object { + fun ok(value: T) = FetchResult(value, null) + fun fail(resource: String, e: Exception? = null) = FetchResult(null, LoadError(resource, detail = e?.message)) + } + } + + private class LoadFailure(val error: LoadError) : Exception("Failed to load ${error.resource}") + } + +private fun encode(value: String) = java.net.URLEncoder.encode(value, Charsets.UTF_8) +private fun JsonObject.str(key: String) = this[key]?.jsonPrimitive?.contentOrNull +private fun JsonObject?.bool(key: String) = this?.get(key)?.jsonPrimitive?.booleanOrNull ?: false diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceManager.kt index 2af74bd151e..93ec913349d 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceManager.kt @@ -7,6 +7,7 @@ import ai.kilocode.log.KiloLog import ai.kilocode.jetbrains.api.client.DefaultApi import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.SharedFlow +import okhttp3.OkHttpClient import java.util.concurrent.ConcurrentHashMap /** @@ -28,6 +29,8 @@ class KiloBackendWorkspaceManager( private val workspaces = ConcurrentHashMap() private var api: DefaultApi? = null + private var http: OkHttpClient? = null + private var port = 0 private var events: SharedFlow? = null /** @@ -35,9 +38,11 @@ class KiloBackendWorkspaceManager( * Called by [KiloBackendAppService] after [KiloAppState.Ready]. * Clears any stale workspaces from a previous connection. */ - fun start(api: DefaultApi, events: SharedFlow) { + fun start(api: DefaultApi, http: OkHttpClient, port: Int, events: SharedFlow) { stop() this.api = api + this.http = http + this.port = port this.events = events log.info("Workspace manager started") } @@ -49,6 +54,8 @@ class KiloBackendWorkspaceManager( workspaces.values.forEach { it.stop() } workspaces.clear() api = null + http = null + port = 0 events = null log.info("Workspace manager stopped") } @@ -59,10 +66,11 @@ class KiloBackendWorkspaceManager( */ fun get(dir: String): KiloBackendWorkspace { val client = api ?: throw IllegalStateException("Workspace manager not started") + val http = http ?: throw IllegalStateException("Workspace manager not started") val ev = events!! return workspaces.computeIfAbsent(dir) { d -> log.info("Creating workspace for $d") - KiloBackendWorkspace(d, cs, client, ev, sessions, log).also { it.load() } + KiloBackendWorkspace(d, cs, client, http, port, ev, sessions, log).also { it.load() } } } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloWorkspaceState.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloWorkspaceState.kt index 7a4ad30b029..0006a331750 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloWorkspaceState.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloWorkspaceState.kt @@ -1,5 +1,7 @@ package ai.kilocode.backend.workspace +import ai.kilocode.backend.app.LoadError + /** * Workspace data lifecycle state, combining connection readiness * with directory-scoped data loading progress. @@ -17,7 +19,7 @@ sealed class KiloWorkspaceState { val commands: List, val skills: List, ) : KiloWorkspaceState() - data class Error(val message: String) : KiloWorkspaceState() + data class Error(val message: String, val errors: List = emptyList()) : KiloWorkspaceState() } /** diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt index e9b6671b9f6..a736b3ddee8 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt @@ -22,6 +22,7 @@ import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue +import kotlin.test.assertContains class KiloBackendAppServiceTest { @@ -66,6 +67,72 @@ class KiloBackendAppServiceTest { assertEquals("claude-4", svc.config!!.model) } + @Test + fun `config warnings are loaded without blocking Ready`() = runBlocking { + mock.warnings = """[{"path":".kilo/kilo.json","message":"Invalid JSON","detail":"CloseBraceExpected"}]""" + val svc = create() + svc.connect() + + withTimeout(10_000) { + svc.appState.first { it is KiloAppState.Ready } + } + + val ready = svc.appState.value as KiloAppState.Ready + assertEquals(1, ready.data.warnings.size) + assertEquals(".kilo/kilo.json", ready.data.warnings.first().path) + assertEquals("Invalid JSON", ready.data.warnings.first().message) + } + + @Test + fun `retry refreshes warnings while Ready`() = runBlocking { + mock.warnings = """[{"path":".kilo/kilo.json","message":"Invalid JSON","detail":"CloseBraceExpected"}]""" + val svc = create() + svc.connect() + + withTimeout(10_000) { + svc.appState.first { it is KiloAppState.Ready } + } + + val before = svc.appState.value as KiloAppState.Ready + assertEquals(1, before.data.warnings.size) + + mock.warnings = "[]" + svc.retry() + + withTimeout(5_000) { + while ((svc.appState.value as? KiloAppState.Ready)?.data?.warnings?.isNotEmpty() == true) { + delay(100) + } + } + + val ready = svc.appState.value as KiloAppState.Ready + assertTrue(ready.data.warnings.isEmpty()) + assertTrue(svc.warnings.isEmpty()) + } + + @Test + fun `retry restarts app when warnings remain after refresh`() = runBlocking { + mock.warnings = """[{"path":".kilo/kilo.json","message":"Invalid JSON","detail":"CloseBraceExpected"}]""" + val svc = create() + svc.connect() + + withTimeout(10_000) { + svc.appState.first { it is KiloAppState.Ready } + } + + val before = mock.requestCount("/global/config") + svc.retry() + + withTimeout(15_000) { + while (mock.requestCount("/global/config") <= before) { + delay(100) + } + } + + assertTrue(mock.requestCount("/global/config") > before) + assertTrue(log.messages.any { it.contains("retry: restarted connection") }) + } + @Test fun `profile is loaded when available`() = runBlocking { mock.profile = """{"profile":{"email":"alice@test.com","name":"Alice"},"balance":null,"currentOrgId":null}""" @@ -126,6 +193,89 @@ class KiloBackendAppServiceTest { assertTrue(err.errors.any { it.resource == "notifications" }) } + @Test + fun `retry reruns load for app load error`() = runBlocking { + mock.configStatus = 500 + mock.config = """{"error":"internal"}""" + val svc = create() + svc.connect() + + withTimeout(15_000) { + svc.appState.first { it is KiloAppState.Error } + } + + assertEquals(3, mock.requestCount("/global/config")) + + mock.configStatus = 200 + mock.config = """{"model":"retry/model"}""" + svc.retry() + + withTimeout(15_000) { + svc.appState.first { it is KiloAppState.Ready } + } + + assertEquals("retry/model", svc.config?.model) + assertEquals(4, mock.requestCount("/global/config")) + } + + @Test + fun `connection error surfaces details as connection load error`() = runBlocking { + val failing = object : ai.kilocode.backend.cli.CliServer { + override var forceExtract = false + override fun process(): Process? = null + override suspend fun init() = ai.kilocode.backend.cli.CliServer.State.Error( + message = "CLI startup failed", + details = "stderr: missing dependency", + ) + override fun exited(proc: Process) {} + override fun stop() {} + override fun dispose() {} + } + val svc = KiloBackendAppService.create(scope, failing, log) + svc.connect() + + withTimeout(5_000) { + svc.appState.first { it is KiloAppState.Error } + } + + val err = svc.appState.value as KiloAppState.Error + assertEquals("CLI startup failed", err.message) + assertContains(err.errors.map { it.resource }, "connection") + assertEquals("stderr: missing dependency", err.errors.first { it.resource == "connection" }.detail) + assertTrue(log.messages.any { it.contains("App error: CLI startup failed") }) + } + + @Test + fun `warning state emits final warn log`() = runBlocking { + mock.warnings = """[{"path":".kilo/kilo.json","message":"Invalid JSON","detail":"CloseBraceExpected"}]""" + val svc = create() + svc.connect() + + withTimeout(10_000) { + svc.appState.first { it is KiloAppState.Ready } + } + + assertTrue(log.messages.any { + it.contains("App warnings:") && it.contains(".kilo/kilo.json: Invalid JSON") + }) + } + + @Test + fun `app load error emits final warn log`() = runBlocking { + mock.configStatus = 500 + mock.config = """{"error":"internal"}""" + val svc = create() + svc.connect() + + withTimeout(15_000) { + svc.appState.first { it is KiloAppState.Error } + } + + assertTrue(log.messages.any { + it.contains("App error: Failed to load required data") && it.contains("config") + }) + } + @Test fun `connect when already Ready is no-op`() = runBlocking { val svc = create() @@ -170,18 +320,18 @@ class KiloBackendAppServiceTest { } @Test - fun `profile 500 transitions to Error`() = runBlocking { + fun `profile 500 does not prevent Ready`() = runBlocking { mock.profileStatus = 500 mock.profile = """{"error":"internal"}""" val svc = create() svc.connect() withTimeout(15_000) { - svc.appState.first { it is KiloAppState.Error } + svc.appState.first { it is KiloAppState.Ready } } - val err = svc.appState.value as KiloAppState.Error - assertTrue(err.errors.any { it.resource == "profile" }) + assertNull(svc.profile) + assertIs(svc.appState.value) } @Test @@ -247,6 +397,31 @@ class KiloBackendAppServiceTest { assertEquals("updated", svc.config?.model) } + @Test + fun `SSE config updated refreshes warnings`() = runBlocking { + mock.warnings = """[{"path":".kilo/kilo.json","message":"Invalid JSON","detail":"CloseBraceExpected"}]""" + val svc = create() + svc.connect() + + withTimeout(10_000) { + svc.appState.first { it is KiloAppState.Ready } + } + + assertEquals(1, (svc.appState.value as KiloAppState.Ready).data.warnings.size) + + mock.warnings = "[]" + mock.awaitSseConnection() + mock.pushEvent("global.config.updated", """{"type":"global.config.updated"}""") + + withTimeout(5_000) { + while ((svc.appState.value as? KiloAppState.Ready)?.data?.warnings?.isNotEmpty() == true) { + delay(100) + } + } + + assertTrue((svc.appState.value as KiloAppState.Ready).data.warnings.isEmpty()) + } + // ------ Concurrency & lifecycle tests ------ @Test diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendSessionManagerTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendSessionManagerTest.kt index d98d978f5f3..867ad3ecbf7 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendSessionManagerTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendSessionManagerTest.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout +import java.net.URLDecoder import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals @@ -121,6 +122,69 @@ class KiloBackendSessionManagerTest { assertEquals("busy", result.statuses["ses_1"]?.type) } + @Test + fun `recent returns global sessions from experimental endpoint`() = runBlocking { + mock.recentSessions = """[ + {"id":"ses_1","slug":"s1","projectID":"prj","directory":"/repo","title":"Session 1","version":"1","time":{"created":1000,"updated":5000},"project":{"id":"prj","worktree":"/repo","name":"Repo"},"summary":{"additions":10,"deletions":2,"files":3}}, + {"id":"ses_2","slug":"s2","projectID":"prj","directory":"/repo-wt","title":"Session 2","version":"1","time":{"created":2000,"updated":4000},"project":{"id":"prj","worktree":"/repo","name":"Repo"},"parentID":"ses_parent"} + ]""" + val app = setup() + ready(app) + + val result = app.sessions.recent("/repo", 5) + + assertEquals(2, result.sessions.size) + assertEquals("ses_1", result.sessions[0].id) + assertEquals("Session 1", result.sessions[0].title) + assertEquals("/repo", result.sessions[0].directory) + assertEquals(10, result.sessions[0].summary?.additions) + assertEquals("ses_parent", result.sessions[1].parentID) + } + + @Test + fun `recent passes worktree filters and limit`() = runBlocking { + val app = setup() + ready(app) + + app.sessions.recent("/repo path", 5) + + val path = mock.lastExperimentalSessionPath ?: error("missing experimental session request") + assertTrue(path.startsWith("/experimental/session?")) + assertTrue(URLDecoder.decode(path, "UTF-8").contains("directory=/repo path"), path) + assertTrue(path.contains("worktrees=true"), path) + assertTrue(path.contains("roots=true"), path) + assertTrue(path.contains("limit=5.0"), path) + assertTrue(path.contains("archived=false"), path) + } + + @Test + fun `recent filters statuses to returned sessions`() = runBlocking { + mock.recentSessions = """[ + {"id":"ses_1","slug":"s1","projectID":"prj","directory":"/repo","title":"Session 1","version":"1","time":{"created":1,"updated":1},"project":{"id":"prj","worktree":"/repo","name":"Repo"}} + ]""" + mock.sessionStatuses = """{ + "ses_1": {"type":"busy","attempt":0,"message":"running","next":0,"requestID":""}, + "ses_other": {"type":"idle","attempt":0,"message":"","next":0,"requestID":""} + }""" + val app = setup() + ready(app) + + val result = app.sessions.recent("/repo", 5) + + assertEquals(setOf("ses_1"), result.statuses.keys) + assertEquals("busy", result.statuses["ses_1"]?.type) + assertEquals("running", result.statuses["ses_1"]?.message) + } + + @Test + fun `recent throws when not started`() = runBlocking { + val app = setup() + + assertFailsWith { + app.sessions.recent("/test", 5) + } + } + // ------ Session create ------ @Test diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloConnectionServiceTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloConnectionServiceTest.kt index 71d75f474c5..de84bfced22 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloConnectionServiceTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloConnectionServiceTest.kt @@ -117,7 +117,7 @@ class KiloConnectionServiceTest { val failing = object : CliServer { override var forceExtract = false override fun process(): Process? = null - override suspend fun init() = CliServer.State.Error("binary not found") + override suspend fun init() = CliServer.State.Error("binary not found", "stderr line 1\nstderr line 2") override fun exited(proc: Process) {} override fun stop() {} override fun dispose() {} @@ -131,6 +131,7 @@ class KiloConnectionServiceTest { } val err = svc.state.value as ConnectionState.Error assertEquals("binary not found", err.message) + assertEquals("stderr line 1\nstderr line 2", err.details) } @Test diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt index 60b27d1fa9a..3c5010736f3 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt @@ -30,10 +30,12 @@ class MockCliServer : AutoCloseable { // Configurable REST responses — can be changed between requests @Volatile var health = """{"healthy":true,"version":"1.0.0"}""" @Volatile var config = """{"model":"test/model"}""" + @Volatile var warnings = "[]" @Volatile var notifications = "[]" @Volatile var profile = """{"profile":{"email":"test@test.com","name":"Test"},"balance":null,"currentOrgId":null}""" @Volatile var profileStatus = 200 @Volatile var configStatus = 200 + @Volatile var warningsStatus = 200 @Volatile var notificationsStatus = 200 // Project-scoped REST responses @@ -48,9 +50,11 @@ class MockCliServer : AutoCloseable { // Session REST responses @Volatile var sessions = "[]" + @Volatile var recentSessions = "[]" @Volatile var sessionCreate = """{"id":"ses_test","slug":"test","projectID":"prj_test","directory":"/test","title":"New Session","version":"1.0.0","time":{"created":1000,"updated":1000}}""" @Volatile var sessionStatuses = "{}" @Volatile var sessionsStatus = 200 + @Volatile var recentSessionsStatus = 200 @Volatile var sessionCreateStatus = 200 @Volatile var sessionGetStatus = 200 @Volatile var sessionDeleteStatus = 200 @@ -65,6 +69,8 @@ class MockCliServer : AutoCloseable { /** Return the number of requests received for [path] (bare, no query). */ fun requestCount(path: String): Int = counts[path]?.get() ?: 0 + @Volatile var lastExperimentalSessionPath: String? = null + /** Reset all request counters. */ fun resetCounts() { counts.clear() } @@ -184,6 +190,7 @@ class MockCliServer : AutoCloseable { when { path == "/global/health" -> respond(output, 200, health) path == "/global/config" -> respond(output, configStatus, config) + path.startsWith("/config/warnings") -> respond(output, warningsStatus, warnings) path.startsWith("/kilo/notifications") -> respond(output, notificationsStatus, notifications) path.startsWith("/kilo/profile") -> { if (profileStatus == 401) { @@ -197,6 +204,10 @@ class MockCliServer : AutoCloseable { bare == "/agent" -> respond(output, agentsStatus, agents) bare == "/command" -> respond(output, commandsStatus, commands) bare == "/skill" -> respond(output, skillsStatus, skills) + bare == "/experimental/session" -> { + lastExperimentalSessionPath = path + respond(output, recentSessionsStatus, recentSessions) + } bare == "/session/status" -> respond(output, sessionStatusesStatus, sessionStatuses) bare == "/session" && method == "GET" -> respond(output, sessionsStatus, sessions) bare == "/session" && method == "POST" -> respond(output, sessionCreateStatus, sessionCreate) diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt index f1f6c38ebf4..a5b177710e0 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt @@ -179,6 +179,23 @@ class KiloBackendWorkspaceTest { val err = ws.state.value as KiloWorkspaceState.Error assertTrue(err.message.contains("providers")) + assertTrue(err.errors.any { it.resource == "providers" }) + assertTrue(log.messages.any { it.contains("Workspace error [/test/project]: Failed to load:") && it.contains("providers") }) + } + + @Test + fun `providers decode failure includes detail`() = runBlocking { + mock.providers = """{"all":[false],"default":{},"connected":[]}""" + val app = setup() + val ws = ready(app) + + withTimeout(15_000) { + ws.state.first { it is KiloWorkspaceState.Error } + } + + val err = ws.state.value as KiloWorkspaceState.Error + val detail = err.errors.single { it.resource == "providers" }.detail + assertTrue(detail?.isNotBlank() == true) } @Test @@ -236,6 +253,7 @@ class KiloBackendWorkspaceTest { val err = ws.state.value as KiloWorkspaceState.Error assertTrue(err.message.contains("providers") || err.message.contains("skills")) + assertTrue(err.errors.any { it.resource == "providers" } || err.errors.any { it.resource == "skills" }) } // ------ Reload ------ diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt index e44b34367dc..faa8477da37 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt @@ -1,13 +1,12 @@ package ai.kilocode.client -import ai.kilocode.client.app.KiloAppService -import ai.kilocode.client.app.KiloSessionService -import ai.kilocode.client.session.SessionUi +import ai.kilocode.client.actions.NewSessionAction import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.app.Workspace +import ai.kilocode.client.session.SessionSidePanelManager +import ai.kilocode.log.KiloLog import com.intellij.openapi.actionSystem.ActionManager import com.intellij.openapi.components.service -import ai.kilocode.log.KiloLog import com.intellij.openapi.project.DumbAware import com.intellij.openapi.project.Project import com.intellij.openapi.wm.ToolWindow @@ -20,7 +19,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext /** - * Creates the Kilo Code tool window with a single [SessionUi]. + * Creates the Kilo Code tool window and delegates session content management. * * Resolves the project directory through the backend (handles split-mode * where `project.basePath` is a synthetic frontend path) before creating @@ -36,8 +35,6 @@ class KiloToolWindowFactory : ToolWindowFactory, DumbAware { override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { try { val workspaces = service() - val sessions = project.service() - val app = service() val cs = CoroutineScope(SupervisorJob()) val hint = project.basePath ?: "" @@ -45,7 +42,7 @@ class KiloToolWindowFactory : ToolWindowFactory, DumbAware { val dir = workspaces.resolveProjectDirectory(hint) val workspace = workspaces.workspace(dir) withContext(Dispatchers.Main) { - setup(project, toolWindow, workspace, sessions, app, cs) + setup(project, toolWindow, workspace) } } } catch (e: Exception) { @@ -57,19 +54,17 @@ class KiloToolWindowFactory : ToolWindowFactory, DumbAware { project: Project, toolWindow: ToolWindow, workspace: Workspace, - sessions: KiloSessionService, - app: KiloAppService, - cs: CoroutineScope, ) { try { - val ui = SessionUi(project, workspace, sessions, app, cs) - val content = ContentFactory.getInstance() - .createContent(ui, "", false) - content.setDisposer(ui) + val manager = SessionSidePanelManager(project, workspace) + val content = ContentFactory.getInstance().createContent(manager.component, "", false) + content.setDisposer(manager) toolWindow.contentManager.addContent(content) + toolWindow.contentManager.setSelectedContent(content) + manager.newSession() - ActionManager.getInstance().getAction("Kilo.Settings")?.let { - toolWindow.setTitleActions(listOf(it)) + ActionManager.getInstance().getAction("Kilo.Settings")?.let { settings -> + toolWindow.setTitleActions(listOf(NewSessionAction(), settings)) } } catch (e: Exception) { LOG.error("Failed to set up Kilo tool window content", e) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/KiloSettingsAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/KiloSettingsAction.kt index 974bd38458f..b7f6ba5d12f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/KiloSettingsAction.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/KiloSettingsAction.kt @@ -29,7 +29,7 @@ class KiloSettingsAction : AnAction() { group, e.dataContext, JBPopupFactory.ActionSelectionAid.SPEEDSEARCH, - true, // showDisabledActions — StatusInfoAction is always disabled + true, ) .showUnderneathOf(component) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/NewSessionAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/NewSessionAction.kt new file mode 100644 index 00000000000..dbe8aefcd6c --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/NewSessionAction.kt @@ -0,0 +1,22 @@ +package ai.kilocode.client.actions + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.SessionManager +import com.intellij.icons.AllIcons +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.project.DumbAware + +class NewSessionAction : AnAction( + KiloBundle.message("action.Kilo.NewSession.text"), + KiloBundle.message("action.Kilo.NewSession.description"), + AllIcons.General.Add, +), DumbAware { + override fun actionPerformed(e: AnActionEvent) { + e.getData(SessionManager.KEY)?.newSession() + } + + override fun update(e: AnActionEvent) { + e.presentation.isEnabled = e.getData(SessionManager.KEY) != null + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/ReinstallKiloAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/ReinstallKiloAction.kt index 242498d9b59..7eab2842c67 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/ReinstallKiloAction.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/ReinstallKiloAction.kt @@ -5,8 +5,9 @@ import ai.kilocode.rpc.dto.KiloAppStatusDto import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.components.service +import com.intellij.openapi.project.DumbAware -class ReinstallKiloAction : AnAction() { +class ReinstallKiloAction : AnAction(), DumbAware { override fun actionPerformed(e: AnActionEvent) { service().reinstallAsync() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/RestartKiloAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/RestartKiloAction.kt index f69c8c9668d..335cf611f0f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/RestartKiloAction.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/RestartKiloAction.kt @@ -5,8 +5,9 @@ import ai.kilocode.rpc.dto.KiloAppStatusDto import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.components.service +import com.intellij.openapi.project.DumbAware -class RestartKiloAction : AnAction() { +class RestartKiloAction : AnAction(), DumbAware { override fun actionPerformed(e: AnActionEvent) { service().restartAsync() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/StatusInfoAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/StatusInfoAction.kt deleted file mode 100644 index 7f808f158ae..00000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/StatusInfoAction.kt +++ /dev/null @@ -1,32 +0,0 @@ -package ai.kilocode.client.actions - -import ai.kilocode.client.app.KiloAppService -import ai.kilocode.client.plugin.KiloBundle -import ai.kilocode.rpc.dto.KiloAppStatusDto -import com.intellij.openapi.actionSystem.AnAction -import com.intellij.openapi.actionSystem.AnActionEvent -import com.intellij.openapi.components.service - -/** - * Non-interactive info row at the bottom of the settings popup showing - * connection status and CLI version (from the last health check). - */ -class StatusInfoAction : AnAction() { - override fun actionPerformed(e: AnActionEvent) { - // intentionally non-actionable - } - - override fun update(e: AnActionEvent) { - val svc = service() - val status = when (svc.state.value.status) { - 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" - e.presentation.isEnabled = false - } -} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt index 01b9e201e3e..878d0a393db 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt @@ -73,6 +73,11 @@ class KiloAppService internal constructor( null } + suspend fun retry() { + LOG.info("retry: sending RPC") + call { retry() } + } + /** Kill the CLI process and restart it. */ suspend fun restart() { LOG.info("restart: resetting state and sending RPC") @@ -97,6 +102,11 @@ class KiloAppService internal constructor( cs.launch { restart() } } + fun retryAsync() { + LOG.info("retryAsync: launching retry") + cs.launch { retry() } + } + /** Fire-and-forget reinstall from non-suspend context (e.g. action handlers). */ fun reinstallAsync() { LOG.info("reinstallAsync: launching reinstall") diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt index 063922fcdb4..1b36aeefb02 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt @@ -34,7 +34,7 @@ import kotlinx.coroutines.launch * Project-level frontend service for session management. * * Stateless with respect to "active session" — callers pass explicit - * session IDs. [ai.kilocode.client.session.SessionController] owns the + * session IDs. [ai.kilocode.client.session.update.SessionController] owns the * active session concept. */ @Service(Service.Level.PROJECT) @@ -84,6 +84,10 @@ class KiloSessionService internal constructor( } } + /** Load recent sessions for the current worktree family. */ + suspend fun recent(dir: String, limit: Int): List = + call { recent(dir, limit) }.sessions + /** Create a new session. Caller awaits the result. */ suspend fun create(dir: String): SessionDto { LOG.info("create: dir=$dir") diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt index 0909d2a4d74..b63964f745d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt @@ -66,7 +66,7 @@ class KiloWorkspaceService internal constructor( LOG.info("Creating workspace for $directory") val state = stream { state(directory) } .stateIn(cs, SharingStarted.Eagerly, INIT) - Workspace(directory, state) + Workspace(directory, state) { reload(directory) } } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/Workspace.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/Workspace.kt index 7f7cd961814..a490a267ec1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/Workspace.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/Workspace.kt @@ -13,4 +13,5 @@ import kotlinx.coroutines.flow.StateFlow class Workspace( val directory: String, val state: StateFlow, + val reload: () -> Unit, ) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionControllerEvent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionControllerEvent.kt deleted file mode 100644 index 6026aed5e28..00000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionControllerEvent.kt +++ /dev/null @@ -1,32 +0,0 @@ -package ai.kilocode.client.session - -import ai.kilocode.client.session.model.SessionModel -import ai.kilocode.client.session.model.SessionModelEvent - -/** - * Lifecycle events fired by [SessionController] on the EDT. - * - * These cover app/workspace state changes and view switching — things - * outside the [SessionModel] domain. For model mutations (messages, - * parts, state), listen to [SessionModelEvent] on [SessionModel] directly. - */ -sealed class SessionControllerEvent { - - // App + workspace lifecycle (every state transition) - data object AppChanged : SessionControllerEvent() - data object WorkspaceChanged : SessionControllerEvent() - - // Workspace ready (pickers populated) - data object WorkspaceReady : SessionControllerEvent() - data class ViewChanged(val show: Boolean) : SessionControllerEvent() { - override fun toString() = if (show) "ViewChanged show" else "ViewChanged hide" - } -} - -/** - * Listener for [SessionControllerEvent]s fired by [SessionController]. - * All callbacks are guaranteed to run on the EDT. - */ -fun interface SessionControllerListener { - fun onEvent(event: SessionControllerEvent) -} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionManager.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionManager.kt new file mode 100644 index 00000000000..a65cde6df18 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionManager.kt @@ -0,0 +1,14 @@ +package ai.kilocode.client.session + +import ai.kilocode.rpc.dto.SessionDto +import com.intellij.openapi.actionSystem.DataKey + +interface SessionManager { + companion object { + val KEY = DataKey.create("ai.kilocode.client.session.SessionManager") + } + + fun newSession() + + fun openSession(session: SessionDto) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt new file mode 100644 index 00000000000..afc5d989606 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt @@ -0,0 +1,84 @@ +package ai.kilocode.client.session + +import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.app.Workspace +import ai.kilocode.rpc.dto.SessionDto +import com.intellij.openapi.Disposable +import com.intellij.openapi.actionSystem.DataProvider +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import java.awt.BorderLayout +import javax.swing.JPanel + +class SessionSidePanelManager( + private val project: Project, + private val root: Workspace, + private val create: (Project, Workspace, SessionManager, String?, Boolean) -> SessionUi = { project, workspace, manager, id, loading -> + service().create(project, workspace, manager, id, loading) + }, + private val resolve: (String) -> Workspace = { dir -> service().workspace(dir) }, +) : SessionManager, Disposable { + val component: JPanel = object : JPanel(BorderLayout()), DataProvider { + override fun getData(dataId: String): Any? { + if (SessionManager.KEY.`is`(dataId)) return this@SessionSidePanelManager + return null + } + } + + private val opened = mutableMapOf() + private val all = mutableSetOf() + private var current: SessionUi? = null + + override fun newSession() { + val active = current + if (active?.blank == true) return + register(active) + show(create(project, root, this, null, active == null)) + } + + override fun openSession(session: SessionDto) { + register(current) + val ui = opened.getOrPut(session.id) { + create(project, resolve(session.directory), this, session.id, false).also { + all.add(it) + } + } + show(ui) + } + + private fun show(ui: SessionUi) { + all.add(ui) + if (current === ui) return + release(current) + component.removeAll() + current = ui + component.add(ui, BorderLayout.CENTER) + component.revalidate() + component.repaint() + } + + private fun register(ui: SessionUi?) { + val id = ui?.id ?: return + opened.putIfAbsent(id, ui) + } + + private fun release(ui: SessionUi?) { + if (ui == null) return + if (ui.id != null) { + register(ui) + return + } + all.remove(ui) + Disposer.dispose(ui) + } + + override fun dispose() { + val items = all.toList() + opened.clear() + all.clear() + current = null + component.removeAll() + items.forEach { Disposer.dispose(it) } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index b9b4856c4d2..7fb382d00b9 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -3,153 +3,228 @@ package ai.kilocode.client.session import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloSessionService import ai.kilocode.client.app.Workspace +import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.client.session.model.SessionState +import ai.kilocode.client.session.ui.ConnectionPanel +import ai.kilocode.client.session.ui.EmptySessionPanel import ai.kilocode.client.session.ui.LabelPicker import ai.kilocode.client.session.ui.PermissionPanel import ai.kilocode.client.session.ui.PromptPanel import ai.kilocode.client.session.ui.QuestionPanel -import ai.kilocode.client.session.ui.SessionPanel -import ai.kilocode.client.session.ui.StatusPanel +import ai.kilocode.client.session.ui.SessionRootPanel +import ai.kilocode.client.session.ui.SessionMessageListPanel +import ai.kilocode.client.session.update.EVENT_FLUSH_MS +import ai.kilocode.client.session.update.SessionController +import ai.kilocode.client.session.update.SessionControllerEvent +import ai.kilocode.rpc.dto.SessionDto +import ai.kilocode.log.ChatLogSummary +import ai.kilocode.log.KiloLog import com.intellij.openapi.Disposable import com.intellij.openapi.project.Project import com.intellij.openapi.util.registry.Registry -import ai.kilocode.log.ChatLogSummary -import ai.kilocode.log.KiloLog +import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBScrollPane +import com.intellij.util.ui.Centerizer import com.intellij.util.ui.JBUI import kotlinx.coroutines.CoroutineScope import java.awt.BorderLayout -import java.awt.CardLayout import javax.swing.BoxLayout +import javax.swing.BoxLayout.Y_AXIS import javax.swing.JPanel /** - * Top-level session UI — a thin composition root. - * - * Responsibilities: - * - Creates and wires [SessionController], [SessionPanel], [StatusPanel], - * [PromptPanel], [QuestionPanel], [PermissionPanel]. - * - Switches between the status (loading) card and the transcript card via - * [SessionControllerEvent.ViewChanged]. - * - Delegates all transcript and dock updates to the panels themselves via - * [SessionModelEvent] listeners (no inline rendering logic here). - * - Scrolls to the bottom on new content. + * Top-level session UI composition root. * - * Views must never call RPC or services directly; everything goes through - * the controller. + * It builds the session panels, wires controller/model listeners, and swaps the + * center body between the empty state and the message list. */ -class SessionUi( +class SessionUi private constructor( project: Project, workspace: Workspace, sessions: KiloSessionService, app: KiloAppService, cs: CoroutineScope, + id: String?, + displayMs: Long, + open: (SessionDto) -> Unit, + private val loading: Boolean, ) : JPanel(BorderLayout()), Disposable { + constructor( + project: Project, + workspace: Workspace, + sessions: KiloSessionService, + app: KiloAppService, + cs: CoroutineScope, + id: String? = null, + displayMs: Long = SessionController.DISPLAY_DELAY_MS, + open: (SessionDto) -> Unit = {}, + ) : this(project, workspace, sessions, app, cs, id, displayMs, open, id == null) + + internal constructor( + project: Project, + workspace: Workspace, + sessions: KiloSessionService, + app: KiloAppService, + cs: CoroutineScope, + id: String? = null, + displayMs: Long = SessionController.DISPLAY_DELAY_MS, + loading: Boolean, + open: (SessionDto) -> Unit = {}, + ) : this(project, workspace, sessions, app, cs, id, displayMs, open, loading) + companion object { - private const val STATUS = "status" - private const val MESSAGES = "messages" private val LOG = KiloLog.create(SessionUi::class.java) } - private val flushMs = Registry.intValue("kilo.session.flushMs", EVENT_FLUSH_MS.toInt()) - .takeIf { it > 0 } - ?.toLong() - ?: EVENT_FLUSH_MS + private val project = project + private val flushMs = + Registry.intValue("kilo.session.flushMs", EVENT_FLUSH_MS.toInt()) + .takeIf { it > 0 } + ?.toLong() + ?: EVENT_FLUSH_MS private val controller = SessionController( - this, null, sessions, workspace, app, cs, this, + this, id, sessions, workspace, app, cs, this, flushMs = flushMs, condense = Registry.`is`("kilo.session.condense", true), + displayMs = displayMs, + open = open, ) - // ------ card switch ------ - private val cards = CardLayout() - private val center = JPanel(cards) + private lateinit var root: SessionRootPanel + + private lateinit var sessionContent: JPanel + + private lateinit var blankBody: JPanel - // ------ status (loading) panel ------ + private lateinit var progressBody: JPanel - private val status = StatusPanel(this, controller) + private lateinit var messageBody: SessionMessageListPanel - // ------ transcript ------ + private lateinit var scroll: JBScrollPane - private val transcript = SessionPanel(controller.model, this) + private lateinit var question: QuestionPanel + private lateinit var permission: PermissionPanel + private lateinit var connection: ConnectionPanel - private val scroll = JBScrollPane(transcript).apply { - border = JBUI.Borders.empty() - verticalScrollBarPolicy = JBScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED - horizontalScrollBarPolicy = JBScrollPane.HORIZONTAL_SCROLLBAR_NEVER + private lateinit var prompt: PromptPanel + + init { + buildUi() + bindUi() + showBody(if (loading) progressBody else blankBody) } - // ------ dock panels (above prompt) ------ + internal val blank: Boolean get() = controller.blank - private val question = QuestionPanel(controller) - private val permission = PermissionPanel(controller) + internal val id: String? get() = controller.id - // ------ prompt ------ + private fun buildUi() { + root = SessionRootPanel() - private val prompt = PromptPanel( - project = project, - onSend = { text -> send(text) }, - onAbort = { controller.abort() }, - ) + sessionContent = JPanel(BorderLayout()) - init { - // South area: question dock, permission dock, and prompt stacked vertically. - // BoxLayout(Y_AXIS) collapses invisible panels to zero height automatically, - // so hiding a dock doesn't leave an empty gap above the prompt. - val south = JPanel().apply { - layout = BoxLayout(this, BoxLayout.Y_AXIS) + blankBody = JPanel(BorderLayout()).apply { isOpaque = false - add(question) - add(permission) - add(prompt) } - center.add(status, STATUS) - center.add(scroll, MESSAGES) - cards.show(center, STATUS) + progressBody = JPanel(BorderLayout()).apply { + isOpaque = false + add(Centerizer( + JBLabel(KiloBundle.message("session.empty.loading")), + Centerizer.TYPE.BOTH, + ), BorderLayout.CENTER) + } + messageBody = SessionMessageListPanel(controller.model, this) - add(center, BorderLayout.CENTER) - add(south, BorderLayout.SOUTH) + scroll = JBScrollPane(blankBody).apply { + border = JBUI.Borders.empty() + verticalScrollBarPolicy = JBScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED + horizontalScrollBarPolicy = JBScrollPane.HORIZONTAL_SCROLLBAR_NEVER + } + question = QuestionPanel(controller) + permission = PermissionPanel(controller) + connection = ConnectionPanel(this, controller) + + prompt = PromptPanel( + project = project, + onSend = { text -> sendPrompt(text) }, + onAbort = { controller.abort() }, + ) + + sessionContent.add(scroll, BorderLayout.CENTER) + root.content.add(sessionContent, BorderLayout.CENTER) + // Dock panels stay in normal flow so each visible state takes layout space + // above the prompt. + root.content.add(JPanel().apply { + this.layout = BoxLayout(this, Y_AXIS) + add(question) + add(permission) + add(connection) + add(prompt) + }, BorderLayout.SOUTH) - // ------ picker wiring ------ + add(root, BorderLayout.CENTER) + } + private fun bindUi() { prompt.mode.onSelect = { item -> controller.selectAgent(item.id) } prompt.model.onSelect = picker@{ item -> val group = item.group ?: return@picker controller.selectModel(group, item.id) } - // ------ controller lifecycle events ------ - controller.addListener(this) { event -> when (event) { is SessionControllerEvent.WorkspaceReady -> { val m = controller.model - prompt.mode.setItems(m.agents.map { LabelPicker.Item(it.name, it.display) }, m.agent) - val items = m.models.map { LabelPicker.Item(it.id, it.display, it.provider) } - val selected = m.model?.let { full -> items.firstOrNull { "${it.group}/${it.id}" == full }?.id } + prompt.mode.setItems(m.agents.map { + LabelPicker.Item( + it.name, + it.display + ) + }, m.agent) + val items = m.models.map { + LabelPicker.Item( + it.id, + it.display, + it.provider + ) + } + val selected = + m.model?.let { full -> items.firstOrNull { "${it.group}/${it.id}" == full }?.id } prompt.model.setItems(items, selected) prompt.setReady(m.isReady()) } - is SessionControllerEvent.ViewChanged -> - cards.show(center, if (event.show) MESSAGES else STATUS) + is SessionControllerEvent.ViewChanged.ShowProgress -> { + showBody(progressBody) + } + + is SessionControllerEvent.ViewChanged.ShowRecents -> { + val panel = EmptySessionPanel(this, controller, event.recents) + showBody(panel) + } + + is SessionControllerEvent.ViewChanged.ShowSession -> { + showBody(messageBody) + } is SessionControllerEvent.AppChanged, - is SessionControllerEvent.WorkspaceChanged -> + is SessionControllerEvent.WorkspaceChanged -> { prompt.setReady(controller.model.isReady()) + } + + is SessionControllerEvent.ConnectionChanged -> Unit } } - // ------ model events — prompt state + dock + auto-scroll ------ - controller.model.addListener(this) { event -> when (event) { - is SessionModelEvent.StateChanged -> onState(event.state) + is SessionModelEvent.StateChanged -> onStateChanged(event.state) is SessionModelEvent.TurnAdded, is SessionModelEvent.TurnUpdated, @@ -171,9 +246,7 @@ class SessionUi( } } - // ------ private helpers ------ - - private fun send(text: String) { + private fun sendPrompt(text: String) { if (text.isBlank()) return LOG.debug { "${ChatLogSummary.prompt(text)} agent=${controller.model.agent ?: "none"} model=${controller.model.model ?: "none"} ready=${controller.ready}" @@ -182,22 +255,25 @@ class SessionUi( prompt.clear() } - private fun onState(state: SessionState) { + private fun onStateChanged(state: SessionState) { prompt.setBusy(state.isBusy()) when (state) { is SessionState.AwaitingQuestion -> { permission.hidePanel() question.show(state.question) } + is SessionState.AwaitingPermission -> { question.hidePanel() permission.show(state.permission) } + else -> { question.hidePanel() permission.hidePanel() } } + refresh() scrollToBottom() } @@ -206,10 +282,17 @@ class SessionUi( bar.value = bar.maximum } - override fun dispose() {} -} + private fun refresh() { + root.revalidate() + root.repaint() + } -private fun SessionState.isBusy(): Boolean = when (this) { - is SessionState.Idle, is SessionState.Error -> false - else -> true + private fun showBody(panel: JPanel) { + if (scroll.viewport.view === panel) return + scroll.viewport.setView(panel) + scroll.revalidate() + scroll.repaint() + } + + override fun dispose() {} } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUiFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUiFactory.kt new file mode 100644 index 00000000000..c55e607d926 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUiFactory.kt @@ -0,0 +1,38 @@ +package ai.kilocode.client.session + +import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloSessionService +import ai.kilocode.client.app.Workspace +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob + +@Service(Service.Level.APP) +class SessionUiFactory( + private val cs: CoroutineScope, +) { + fun create( + project: Project, + workspace: Workspace, + manager: SessionManager, + id: String? = null, + loading: Boolean = id == null, + ): SessionUi = SessionUi( + project = project, + workspace = workspace, + sessions = project.service(), + app = service(), + cs = scope(), + id = id, + loading = loading, + open = manager::openSession, + ) + + private fun scope(): CoroutineScope { + val parent = cs.coroutineContext[Job] + return CoroutineScope(cs.coroutineContext + SupervisorJob(parent)) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt index 4fec5b07bb1..93a1560a676 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt @@ -15,7 +15,7 @@ import com.intellij.openapi.util.Disposer /** * Pure session model — single source of truth for session content and runtime state. * - * **EDT-only access** — no synchronization. [ai.kilocode.client.session.SessionController] guarantees all + * **EDT-only access** — no synchronization. [ai.kilocode.client.session.update.SessionController] guarantees all * reads and writes happen on the EDT. * * In addition to the flat message list, the model maintains a derived @@ -46,7 +46,7 @@ class SessionModel { var models: List = emptyList() var agent: String? = null var model: String? = null - var showMessages: Boolean = false + var showSession: Boolean = false var state: SessionState = SessionState.Idle private set diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionState.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionState.kt index 6226dde8a55..7d0f5f1b67b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionState.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionState.kt @@ -15,4 +15,9 @@ sealed class SessionState { data class Offline(val message: String, val requestId: String) : SessionState() data class Error(val message: String, val kind: String? = null) : SessionState() + + fun isBusy(): Boolean = when (this) { + is Idle, is Error -> false + else -> true + } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt new file mode 100644 index 00000000000..f0adfbf7ce6 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt @@ -0,0 +1,263 @@ +package ai.kilocode.client.session.ui + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.update.SessionController +import ai.kilocode.client.session.update.SessionControllerEvent +import ai.kilocode.client.session.update.SessionControllerListener +import com.intellij.icons.AllIcons +import com.intellij.openapi.Disposable +import com.intellij.openapi.util.Disposer +import com.intellij.ui.JBColor +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.BorderLayout +import java.awt.Cursor +import java.awt.Dimension +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import javax.swing.JPanel +import javax.swing.ScrollPaneConstants +import javax.swing.UIManager + +class ConnectionPanel( + parent: Disposable, + private val controller: SessionController, +) : JPanel(BorderLayout()), SessionControllerListener, Disposable { + + companion object { + private const val DETAILS_LINES = 10 + private val ERROR = JBColor.namedColor("Label.errorForeground", UIUtil.getErrorForeground()) + private val WARNING = JBColor.lazy { + UIManager.getColor("Component.warningFocusColor") + ?: UIManager.getColor("Label.warningForeground") + ?: UIUtil.getContextHelpForeground() + } + } + + private val click = object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + flip() + } + } + + private val header = JPanel(BorderLayout()).apply { + border = JBUI.Borders.empty(4, 8, 0, 8) + isOpaque = false + } + + private val left = JPanel(BorderLayout(JBUI.scale(4), 0)).apply { + isOpaque = false + addMouseListener(click) + } + + private val toggle = JBLabel().apply { + isVisible = false + addMouseListener(click) + } + + private val label = JBLabel().apply { + foreground = UIUtil.getContextHelpForeground() + addMouseListener(click) + } + + private val retry = ActionLink(KiloBundle.message("session.connection.retry")) { + controller.retryConnection() + }.apply { + isVisible = false + horizontalAlignment = JBLabel.RIGHT + isFocusable = false + setRequestFocusEnabled(false) + } + + private val details = JBTextArea().apply { + isEditable = false + isOpaque = false + lineWrap = true + wrapStyleWord = true + foreground = UIUtil.getLabelForeground() + } + + private val scroll = JBScrollPane(details).apply { + border = JBUI.Borders.empty(0, 8, 4, 0) + isOpaque = false + viewport.isOpaque = false + horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED + isVisible = false + } + + private var detail: String? = null + private var expanded = false + + init { + Disposer.register(parent, this) + isOpaque = true + background = UIUtil.getPanelBackground() + border = JBUI.Borders.customLine(UIUtil.getBoundsColor(), 1, 0, 0, 0) + left.add(toggle, BorderLayout.WEST) + left.add(label, BorderLayout.CENTER) + header.add(left, BorderLayout.CENTER) + header.add(retry, BorderLayout.EAST) + add(header, BorderLayout.NORTH) + controller.addListener(this, this) + hidePanel() + } + + override fun onEvent(event: SessionControllerEvent) { + when (event) { + is SessionControllerEvent.ConnectionChanged.Hide -> hidePanel() + + is SessionControllerEvent.ConnectionChanged.ShowConnecting -> showConnecting() + + is SessionControllerEvent.ConnectionChanged.ShowError -> { + showError(event.summary, event.detail) + showPanel() + } + + is SessionControllerEvent.ConnectionChanged.ShowWarning -> { + showWarning(event.summary, event.detail) + showPanel() + } + + else -> Unit + } + } + + private fun showConnecting() { + label.foreground = UIUtil.getContextHelpForeground() + label.text = KiloBundle.message("session.connection.connecting") + detail = null + expanded = false + toggle.isVisible = false + retry.isVisible = false + renderDetails() + showPanel() + } + + private fun showError(text: String, detail: String?) { + label.foreground = ERROR + label.text = text + retry.isVisible = true + this.detail = detail?.takeIf { it.isNotBlank() } + expanded = false + toggle.isVisible = this.detail != null + renderDetails() + } + + private fun showWarning(text: String, detail: String?) { + label.foreground = WARNING + label.text = text + retry.isVisible = true + this.detail = detail?.takeIf { it.isNotBlank() } + expanded = false + toggle.isVisible = this.detail != null + renderDetails() + } + + private fun renderDetails() { + val text = detail + val show = expanded && text != null + val cursor = if (text != null) Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) else Cursor.getDefaultCursor() + toggle.icon = if (expanded) AllIcons.General.ArrowDown else AllIcons.General.ArrowRight + left.cursor = cursor + label.cursor = cursor + toggle.cursor = cursor + details.text = text ?: "" + scroll.isVisible = show + if (show) add(scroll, BorderLayout.CENTER) + else remove(scroll) + } + + private fun flip() { + if (!toggle.isVisible) return + expanded = !expanded + renderDetails() + refresh() + } + + private fun showPanel() { + if (!isVisible) { + isVisible = true + refresh() + return + } + refresh() + } + + private fun hidePanel() { + if (isVisible) { + isVisible = false + refresh() + return + } + refresh() + } + + private fun refresh() { + parent?.revalidate() + parent?.repaint() + revalidate() + repaint() + } + + override fun dispose() { + // no-op + } + + override fun getPreferredSize(): Dimension { + val size = super.getPreferredSize() + if (!scroll.isVisible) return size + return Dimension(size.width, header.preferredSize.height + scrollHeight()) + } + + private fun scrollHeight(): Int { + val rows = details.text.lineSequence().count().coerceIn(1, DETAILS_LINES) + return details.getFontMetrics(details.font).height * rows + scrollChrome() + } + + private fun scrollChrome() = scroll.insets.top + scroll.insets.bottom + JBUI.scale(2) + + internal fun summaryText() = label.text + + internal fun summaryColor() = label.foreground + + internal fun detailsText() = details.text + + internal fun detailsColor() = details.foreground + + internal fun retryVisible() = retry.isVisible + + internal fun retryText() = retry.text + + internal fun detailsVisible() = scroll.isVisible + + internal fun toggleVisible() = toggle.isVisible + + internal fun toggleExpanded() = expanded + + internal fun clickToggle() { + if (!toggle.isVisible) return + toggle.mouseListeners.firstOrNull()?.mouseClicked( + MouseEvent(toggle, MouseEvent.MOUSE_CLICKED, 0, 0, 0, 0, 1, false) + ) + } + + internal fun clickSummary() { + label.mouseListeners.firstOrNull()?.mouseClicked( + MouseEvent(label, MouseEvent.MOUSE_CLICKED, 0, 0, 0, 0, 1, false) + ) + } + + internal fun retryFocusable() = retry.isFocusable + + internal fun clickRetry() = retry.doClick() + + internal fun hasSeparator() = border != null + + internal fun maxExpandedHeight() = + header.preferredSize.height + details.getFontMetrics(details.font).height * DETAILS_LINES + scrollChrome() +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt new file mode 100644 index 00000000000..7757d1e54cf --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt @@ -0,0 +1,238 @@ +package ai.kilocode.client.session.ui + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.update.SessionController +import ai.kilocode.client.ui.md.MdView +import ai.kilocode.rpc.dto.SessionDto +import com.intellij.openapi.Disposable +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.IconLoader +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBList +import com.intellij.util.ui.Centerizer +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import java.awt.BorderLayout +import java.awt.Component +import java.awt.Dimension +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import java.awt.event.MouseMotionAdapter +import javax.swing.Box +import javax.swing.BoxLayout +import javax.swing.DefaultListModel +import javax.swing.JList +import javax.swing.JPanel +import javax.swing.ListCellRenderer +import javax.swing.ListSelectionModel +import kotlin.math.abs + +/** + * Centered empty-session panel. + */ +class EmptySessionPanel( + parent: Disposable, + private val controller: SessionController, + recents: List, +) : JPanel(BorderLayout()), Disposable { + + companion object { + internal const val LIMIT = 5 + internal const val MAX_WIDTH = 350 + private const val MINUTE = 60_000L + private const val HOUR = 60 * MINUTE + private const val DAY = 24 * HOUR + } + + private val model = DefaultListModel() + private var hover = -1 + + private val list = JBList(model).apply { + isOpaque = false + selectionMode = ListSelectionModel.SINGLE_SELECTION + visibleRowCount = LIMIT + cellRenderer = SessionRenderer() + emptyText.clear() + addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + val index = row(e) + if (index < 0) return + selectedIndex = index + controller.openSession(model.getElementAt(index)) + } + + override fun mouseExited(e: MouseEvent) { + hover = -1 + repaint() + } + }) + addMouseMotionListener(object : MouseMotionAdapter() { + override fun mouseMoved(e: MouseEvent) { + val index = row(e) + if (hover == index) return + hover = index + repaint() + } + }) + } + private val md = MdView.html().apply { + opaque = false + foreground = UIUtil.getContextHelpForeground() + set(KiloBundle.message("session.empty.welcome")) + } + private val content = createContent() + + init { + Disposer.register(parent, this) + isOpaque = false + border = JBUI.Borders.empty(12) + setSessions(recents) + add(Centerizer(content, Centerizer.TYPE.BOTH), BorderLayout.CENTER) + } + + private fun setSessions(sessions: List) { + model.clear() + sessions.take(LIMIT).forEach(model::addElement) + revalidate() + repaint() + } + + private fun createContent(): JPanel { + val logo = JBLabel( + IconLoader.getIcon("/icons/kilo-content.svg", EmptySessionPanel::class.java), + ).apply { + alignmentX = CENTER_ALIGNMENT + } + val intro = JPanel(BorderLayout()).apply { + isOpaque = false + alignmentX = CENTER_ALIGNMENT + add(md.component, BorderLayout.CENTER) + border = JBUI.Borders.empty(0, 12, 0, 12) + } + val recent = JPanel(BorderLayout()).apply { + isOpaque = false + alignmentX = CENTER_ALIGNMENT + add(JBLabel(KiloBundle.message("session.empty.recent")).apply { + foreground = UIUtil.getContextHelpForeground() + font = font.deriveFont(font.size2D - 1f) + border = JBUI.Borders.emptyLeft(8) + }, BorderLayout.NORTH) + add(list, BorderLayout.CENTER) + } + val stack = JPanel().apply { + isOpaque = false + layout = BoxLayout(this, BoxLayout.Y_AXIS) + add(logo) + add(Box.createVerticalStrut(JBUI.scale(14))) + add(intro) + add(Box.createVerticalStrut(JBUI.scale(28))) + add(recent) + } + return object : JPanel(BorderLayout()) { + override fun getPreferredSize(): Dimension { + val size = super.getPreferredSize() + return Dimension(JBUI.scale(MAX_WIDTH), size.height) + } + }.apply { + isOpaque = false + add(stack, BorderLayout.NORTH) + } + } + + internal fun recentCount() = model.size() + + internal fun selectRecent(index: Int) { + list.selectedIndex = index + } + + internal fun selectedRecent() = list.selectedIndex + + internal fun clickRecent(index: Int) { + list.selectedIndex = index + controller.openSession(model.getElementAt(index)) + } + + internal fun recentVisible() = true + + internal fun explanationMarkdown() = md.markdown() + + internal fun contentPreferredSize() = content.preferredSize + + internal fun initialized() = true + + internal fun loadingVisible() = false + + internal fun activeView() = getComponent(0) + + internal fun text(session: SessionDto, now: Long = System.currentTimeMillis()) = time(session, now) + + internal fun normalize(value: Double): Long { + val raw = value.toLong() + if (abs(raw) < 10_000_000_000L) return raw * 1000 + return raw + } + + internal fun rendererComponent( + session: SessionDto, + selected: Boolean = false, + hover: Boolean = false, + ): Component { + val old = this.hover + this.hover = if (hover) 0 else -1 + return list.cellRenderer.getListCellRendererComponent(list, session, 0, selected, false).also { + this.hover = old + } + } + + private fun row(e: MouseEvent): Int { + val index = list.locationToIndex(e.point) + if (index < 0) return -1 + val box = list.getCellBounds(index, index) ?: return -1 + if (!box.contains(e.point)) return -1 + return index + } + + private inner class SessionRenderer : JPanel(BorderLayout()), ListCellRenderer { + private val title = JBLabel() + private val time = JBLabel() + + init { + border = JBUI.Borders.empty(8, 8, 8, 8) + add(title, BorderLayout.CENTER) + add(time, BorderLayout.EAST) + } + + override fun getListCellRendererComponent( + list: JList, + value: SessionDto?, + index: Int, + selected: Boolean, + focus: Boolean, + ): Component { + val active = selected || hover == index + isOpaque = active + background = if (active) list.selectionBackground else list.background + title.foreground = if (active) list.selectionForeground else UIUtil.getLabelForeground() + time.foreground = if (active) list.selectionForeground else UIUtil.getContextHelpForeground() + title.text = value?.let(::title) ?: "" + time.text = value?.let { time(it) } ?: "" + return this + } + } + + private fun title(session: SessionDto) = + session.title.takeIf { it.isNotBlank() } ?: KiloBundle.message("session.tab.untitled") + + private fun time(session: SessionDto, now: Long = System.currentTimeMillis()): String { + val ms = normalize(session.time.updated) + val diff = (now - ms).coerceAtLeast(0) + if (diff < MINUTE) return KiloBundle.message("session.empty.time.moments") + if (diff < HOUR) return KiloBundle.message("session.empty.time.minutes", (diff / MINUTE).coerceAtLeast(1)) + if (diff < DAY) return KiloBundle.message("session.empty.time.hours", (diff / HOUR).coerceAtLeast(1)) + return KiloBundle.message("session.empty.time.days", (diff / DAY).coerceAtLeast(1)) + } + + override fun dispose() { + // no-op + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListUi.kt deleted file mode 100644 index b7e98135703..00000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListUi.kt +++ /dev/null @@ -1,282 +0,0 @@ -package ai.kilocode.client.session.ui - -import ai.kilocode.client.session.model.SessionModel -import ai.kilocode.client.session.model.SessionModelEvent -import ai.kilocode.client.session.model.SessionState -import ai.kilocode.client.session.model.Compaction -import ai.kilocode.client.session.model.Content -import ai.kilocode.client.session.model.Generic -import ai.kilocode.client.session.model.Message -import ai.kilocode.client.session.model.Reasoning -import ai.kilocode.client.session.model.Text -import ai.kilocode.client.session.model.Tool -import ai.kilocode.client.session.model.ToolExecState -import com.intellij.openapi.Disposable -import com.intellij.ui.AnimatedIcon -import com.intellij.ui.JBColor -import com.intellij.ui.components.JBLabel -import com.intellij.util.ui.JBUI -import com.intellij.util.ui.UIUtil -import java.awt.BorderLayout -import java.awt.FlowLayout -import javax.swing.BoxLayout -import javax.swing.JPanel -import javax.swing.JTextArea -import javax.swing.border.MatteBorder - -/** - * Scrollable panel displaying session messages aligned to the top, - * with an optional animated status indicator at the bottom. - * - * Passive view — all rendering is driven by [SessionModelEvent]s - * from the [SessionModel]. No public mutation methods. - */ -class MessageListUi( - parent: Disposable, - private val model: SessionModel, -) : JPanel(BorderLayout()) { - - private val blocks = LinkedHashMap() - private var errorLabel: JBLabel? = null - - private val inner = JPanel().apply { - layout = BoxLayout(this, BoxLayout.Y_AXIS) - isOpaque = false - border = JBUI.Borders.empty(4, 8) - } - - private val label = JBLabel().apply { - foreground = UIUtil.getContextHelpForeground() - } - - private val spinner = JPanel(FlowLayout(FlowLayout.LEFT, JBUI.scale(4), 0)).apply { - isOpaque = false - isVisible = false - border = JBUI.Borders.empty(6, 0) - alignmentX = LEFT_ALIGNMENT - add(JBLabel(AnimatedIcon.Default())) - add(label) - } - - init { - isOpaque = true - background = UIUtil.getPanelBackground() - inner.add(spinner) - add(inner, BorderLayout.NORTH) - - model.addListener(parent) { event -> - when (event) { - is SessionModelEvent.MessageAdded -> onAdded(event.info) - is SessionModelEvent.MessageUpdated -> onAdded(event.info) // refresh/upsert - is SessionModelEvent.MessageRemoved -> onRemoved(event.id) - is SessionModelEvent.ContentAdded -> onContentAdded(event.messageId, event.content) - is SessionModelEvent.ContentUpdated -> onContentUpdated(event.messageId, event.content) - is SessionModelEvent.ContentRemoved -> onContentRemoved(event.messageId, event.contentId) - is SessionModelEvent.ContentDelta -> onContentDelta(event.messageId, event.contentId, event.delta) - is SessionModelEvent.StateChanged -> onState(event.state) - is SessionModelEvent.HistoryLoaded -> onHistory() - is SessionModelEvent.Cleared -> onCleared() - is SessionModelEvent.DiffUpdated, - is SessionModelEvent.TodosUpdated, - is SessionModelEvent.Compacted, - is SessionModelEvent.TurnAdded, - is SessionModelEvent.TurnUpdated, - is SessionModelEvent.TurnRemoved -> Unit - } - } - } - - private fun onAdded(info: Message) { - if (blocks.containsKey(info.info.id)) return - val block = MessageBlock(info) - blocks[info.info.id] = block - inner.add(block, inner.componentCount - 1) - refresh() - } - - private fun onRemoved(id: String) { - val block = blocks.remove(id) ?: return - inner.remove(block) - refresh() - } - - private fun onContentAdded(messageId: String, content: Content) { - blocks[messageId]?.addContent(content) - refresh() - } - - private fun onContentUpdated(messageId: String, content: Content) { - blocks[messageId]?.updateContent(content) - refresh() - } - - private fun onContentRemoved(messageId: String, contentId: String) { - blocks[messageId]?.removeContent(contentId) - refresh() - } - - private fun onContentDelta(messageId: String, contentId: String, delta: String) { - blocks[messageId]?.appendDelta(contentId, delta) - refresh() - } - - private fun onState(state: SessionState) { - errorLabel?.let { inner.remove(it); errorLabel = null } - when (state) { - is SessionState.Busy -> { - label.text = state.text - spinner.isVisible = true - } - is SessionState.Error -> { - spinner.isVisible = false - val err = JBLabel(state.message).apply { - foreground = JBColor.RED - font = JBUI.Fonts.label() - border = JBUI.Borders.empty(4, 0) - alignmentX = LEFT_ALIGNMENT - } - errorLabel = err - inner.add(err, inner.componentCount - 1) - } - else -> { - spinner.isVisible = false - } - } - refresh() - } - - private fun onHistory() { - clear() - for (entry in model.messages()) { - val block = MessageBlock(entry) - blocks[entry.info.id] = block - inner.add(block, inner.componentCount - 1) - for ((_, content) in entry.parts) block.addContent(content) - } - refresh() - } - - private fun onCleared() { - clear() - refresh() - } - - private fun clear() { - blocks.clear() - errorLabel = null - inner.removeAll() - inner.add(spinner) - spinner.isVisible = false - } - - private fun refresh() { - revalidate() - repaint() - } -} - -private class MessageBlock(info: Message) : JPanel() { - private val areas = LinkedHashMap() - private val labels = LinkedHashMap() - - init { - layout = BoxLayout(this, BoxLayout.Y_AXIS) - isOpaque = false - alignmentX = LEFT_ALIGNMENT - - border = if (info.info.role == "user") { - JBUI.Borders.compound( - MatteBorder(1, 0, 0, 0, JBColor.border()), - JBUI.Borders.empty(8, 0, 4, 0), - ) - } else { - JBUI.Borders.empty(4, 0) - } - } - - fun addContent(content: Content) { - when (content) { - is Text -> { - val area = createArea() - if (content.content.isNotEmpty()) area.text = content.content.toString() - areas[content.id] = area - add(area) - } - is Reasoning -> { - val area = createArea().apply { - foreground = UIUtil.getContextHelpForeground() - } - if (content.content.isNotEmpty()) area.text = content.content.toString() - areas[content.id] = area - add(area) - } - is Tool -> { - val lbl = createToolLabel(content) - labels[content.id] = lbl - add(lbl) - } - is Compaction -> { - val lbl = JBLabel("Context compacted").apply { - foreground = UIUtil.getContextHelpForeground() - font = JBUI.Fonts.smallFont() - border = JBUI.Borders.empty(4, 0) - alignmentX = LEFT_ALIGNMENT - } - labels[content.id] = lbl - add(lbl) - } - is Generic -> {} // unknown part type — not rendered in this pass - } - revalidate() - } - - fun removeContent(contentId: String) { - areas.remove(contentId)?.let { remove(it) } - labels.remove(contentId)?.let { remove(it) } - revalidate() - } - - fun updateContent(content: Content) { - when (content) { - is Text -> areas[content.id]?.text = content.content.toString() - is Reasoning -> areas[content.id]?.text = content.content.toString() - is Tool -> labels[content.id]?.text = toolText(content) - is Compaction -> {} - is Generic -> {} - } - revalidate() - } - - fun appendDelta(contentId: String, delta: String) { - areas[contentId]?.append(delta) - revalidate() - } - - private fun createArea() = JTextArea().apply { - isEditable = false - lineWrap = true - wrapStyleWord = true - isOpaque = false - font = JBUI.Fonts.label() - foreground = UIUtil.getLabelForeground() - border = JBUI.Borders.empty() - alignmentX = LEFT_ALIGNMENT - } - - private fun createToolLabel(content: Tool) = JBLabel(toolText(content)).apply { - foreground = UIUtil.getContextHelpForeground() - font = JBUI.Fonts.smallFont() - border = JBUI.Borders.empty(2, 0) - alignmentX = LEFT_ALIGNMENT - } - - private fun toolText(content: Tool): String { - val icon = when (content.state) { - ToolExecState.PENDING -> "\u23F3" - ToolExecState.RUNNING -> "\u25B6" - ToolExecState.COMPLETED -> "\u2713" - ToolExecState.ERROR -> "\u2717" - } - return "$icon ${content.title ?: content.name}" - } -} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/PermissionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/PermissionPanel.kt index 0c105e5da22..64319963508 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/PermissionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/PermissionPanel.kt @@ -1,6 +1,6 @@ package ai.kilocode.client.session.ui -import ai.kilocode.client.session.SessionController +import ai.kilocode.client.session.update.SessionController import ai.kilocode.client.session.model.Permission import ai.kilocode.rpc.dto.PermissionReplyDto import com.intellij.icons.AllIcons diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt index 75619241f77..3dea6a3beaf 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt @@ -18,7 +18,7 @@ import java.awt.FlowLayout * - [SessionState.Busy] → shows an animated spinner and [SessionState.Busy.text] * - Any other state → hidden * - * Owned by [SessionPanel], which always re-anchors it as the last child so it + * Owned by [SessionMessageListPanel], which always re-anchors it as the last child so it * appears below all turn views inside the scroll pane. */ class ProgressPanel( diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/QuestionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/QuestionPanel.kt index fcd83a223c1..709f5b08448 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/QuestionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/QuestionPanel.kt @@ -1,6 +1,6 @@ package ai.kilocode.client.session.ui -import ai.kilocode.client.session.SessionController +import ai.kilocode.client.session.update.SessionController import ai.kilocode.client.session.model.Question import ai.kilocode.rpc.dto.QuestionReplyDto import com.intellij.ui.JBColor diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt similarity index 99% rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionPanel.kt rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt index 7a44c4531ef..4708c5a4d49 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt @@ -28,7 +28,7 @@ import com.intellij.util.ui.JBUI * * All method calls must happen on the EDT. */ -class SessionPanel( +class SessionMessageListPanel( private val model: SessionModel, parent: Disposable, ) : SessionLayoutPanel() { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionRootPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionRootPanel.kt new file mode 100644 index 00000000000..cb22774bc6b --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionRootPanel.kt @@ -0,0 +1,77 @@ +package ai.kilocode.client.session.ui + +import java.awt.BorderLayout +import java.awt.Dimension +import java.awt.Rectangle +import javax.swing.JComponent +import javax.swing.JLayeredPane +import javax.swing.JPanel + +class SessionRootPanel : JLayeredPane() { + + val content = JPanel(BorderLayout()) + + val overlay = Overlay() + + init { + layout = null + add(content) + setLayer(content, DEFAULT_LAYER) + add(overlay) + setLayer(overlay, PALETTE_LAYER) + } + + fun addOverlay(child: JComponent, bounds: (JPanel, JComponent) -> Rectangle) { + overlay.addOverlay(child, bounds) + } + + override fun doLayout() { + components + .sortedBy { getLayer(it) } + .forEach { child -> + child.setBounds(0, 0, width, height) + child.doLayout() + } + } + + override fun getPreferredSize(): Dimension { + val w = components.maxOfOrNull { it.preferredSize.width } ?: 0 + val h = components.maxOfOrNull { it.preferredSize.height } ?: 0 + return Dimension(w, h) + } + + class Overlay : JPanel(null) { + + private val items = linkedMapOf Rectangle>() + + init { + isOpaque = false + } + + fun addOverlay(child: JComponent, bounds: (JPanel, JComponent) -> Rectangle) { + items[child] = bounds + add(child) + } + + override fun contains(x: Int, y: Int): Boolean { + for (child in components) { + if (child.isVisible && child.bounds.contains(x, y)) return true + } + return false + } + + override fun doLayout() { + items.forEach { (child, bounds) -> + child.bounds = bounds(this, child) + child.doLayout() + } + } + + override fun getPreferredSize(): Dimension { + val pref = super.getPreferredSize() + val w = maxOf(pref.width, components.maxOfOrNull { it.preferredSize.width } ?: 0) + val h = maxOf(pref.height, components.maxOfOrNull { it.preferredSize.height } ?: 0) + return Dimension(w, h) + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/StatusPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/StatusPanel.kt deleted file mode 100644 index a27b5471129..00000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/StatusPanel.kt +++ /dev/null @@ -1,334 +0,0 @@ -package ai.kilocode.client.session.ui - -import ai.kilocode.client.plugin.KiloBundle -import ai.kilocode.client.session.SessionController -import ai.kilocode.client.session.SessionControllerEvent -import ai.kilocode.client.session.SessionControllerListener -import ai.kilocode.rpc.dto.KiloAppStateDto -import ai.kilocode.rpc.dto.KiloAppStatusDto -import ai.kilocode.rpc.dto.KiloWorkspaceStateDto -import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto -import ai.kilocode.rpc.dto.ProfileStatusDto -import com.intellij.icons.AllIcons -import com.intellij.openapi.Disposable -import com.intellij.openapi.util.Disposer -import com.intellij.openapi.util.IconLoader -import com.intellij.ui.AnimatedIcon -import com.intellij.ui.components.JBLabel -import com.intellij.util.ui.JBUI -import com.intellij.util.ui.UIUtil -import java.awt.Font -import java.awt.GridBagConstraints -import java.awt.GridBagLayout -import javax.swing.Box -import javax.swing.BoxLayout -import javax.swing.Icon -import javax.swing.JPanel -import javax.swing.SwingConstants - -/** - * Welcome panel showing app + workspace initialization progress. - * - * Pure view — listens to [SessionController] events and reads - * [SessionModel][ai.kilocode.client.session.model.SessionModel] for data. - * No coroutines, no service references. - * - * Uses icon+label rows for each resource being loaded. Icons act as - * status indicators: animated spinner for loading, green check for - * success, red circle for error, grey circle for idle. - */ -class StatusPanel( - parent: Disposable, - private val controller: SessionController, -) : JPanel(GridBagLayout()), SessionControllerListener, Disposable { - - init { - Disposer.register(parent, this) - } - - // ------ status icons ------ - - private val iconLoading: Icon = AnimatedIcon.Default() - private val iconOk: Icon = AllIcons.RunConfigurations.TestPassed - private val iconError: Icon = AllIcons.RunConfigurations.TestFailed - private val iconWarn: Icon = AllIcons.General.Warning - private val iconIdle: Icon = AllIcons.RunConfigurations.TestNotRan - - // ------ header ------ - - private val logo = JBLabel( - IconLoader.getIcon("/icons/kilo-content.svg", StatusPanel::class.java), - ).apply { - alignmentX = CENTER_ALIGNMENT - } - - private val status = JBLabel().apply { - alignmentX = CENTER_ALIGNMENT - horizontalAlignment = SwingConstants.CENTER - font = JBUI.Fonts.label(13f) - foreground = UIUtil.getLabelForeground() - } - - // ------ app rows ------ - - private val configRow = row(KiloBundle.message("toolwindow.row.config")) - private val notifRow = row(KiloBundle.message("toolwindow.row.notifications")) - private val profileRow = row(KiloBundle.message("toolwindow.row.profile")) - - // ------ workspace rows ------ - - private val providersRow = row(KiloBundle.message("toolwindow.row.providers")) - private val agentsRow = row(KiloBundle.message("toolwindow.row.agents")) - private val commandsRow = row(KiloBundle.message("toolwindow.row.commands")) - private val skillsRow = row(KiloBundle.message("toolwindow.row.skills")) - - // ------ section headers ------ - - private val appHeader = header(KiloBundle.message("toolwindow.section.app")) - private val wsHeader = header(KiloBundle.message("toolwindow.section.workspace")) - - private val appSection = section(appHeader, configRow, notifRow, profileRow) - private val wsSection = section(wsHeader, providersRow, agentsRow, commandsRow, skillsRow) - - init { - isOpaque = false - - val body = JPanel().apply { - layout = BoxLayout(this, BoxLayout.Y_AXIS) - isOpaque = false - border = JBUI.Borders.empty(12, 16) - - add(logo) - add(Box.createVerticalStrut(JBUI.scale(12))) - add(status) - add(Box.createVerticalStrut(JBUI.scale(12))) - add(appSection) - add(Box.createVerticalStrut(JBUI.scale(12))) - add(wsSection) - } - - add(body, GridBagConstraints()) - - resetAll() - controller.addListener(this, this) - } - - override fun onEvent(event: SessionControllerEvent) { - when (event) { - is SessionControllerEvent.AppChanged -> { - renderApp(controller.model.app) - revalidate() - repaint() - } - - is SessionControllerEvent.WorkspaceChanged -> { - renderWorkspace(controller.model.workspace) - revalidate() - repaint() - } - - else -> {} - } - } - - // ------ rendering ------ - - private fun renderApp(state: KiloAppStateDto) { - status.text = title(state) - - when (state.status) { - KiloAppStatusDto.DISCONNECTED -> { - resetAll() - } - KiloAppStatusDto.CONNECTING -> { - configRow.loading() - notifRow.loading() - profileRow.loading() - } - KiloAppStatusDto.LOADING -> { - val p = state.progress - if (p != null) { - if (p.config) configRow.ok(KiloBundle.message("toolwindow.row.config")) else configRow.loading() - if (p.notifications) notifRow.ok(KiloBundle.message("toolwindow.row.notifications")) else notifRow.loading() - renderProfile(p.profile) - } - } - KiloAppStatusDto.READY -> { - val p = state.progress - if (p != null) { - configRow.ok(KiloBundle.message("toolwindow.row.config")) - notifRow.ok(KiloBundle.message("toolwindow.row.notifications")) - renderProfile(p.profile) - } else { - configRow.ok(KiloBundle.message("toolwindow.row.config")) - notifRow.ok(KiloBundle.message("toolwindow.row.notifications")) - profileRow.ok(KiloBundle.message("toolwindow.profile.loggedin")) - } - } - KiloAppStatusDto.ERROR -> { - val errors = state.errors.associate { it.resource to it } - configRow.apply { - val detail = errors["config"]?.detail ?: KiloBundle.message("toolwindow.error.failed") - if ("config" in errors) error(KiloBundle.message("toolwindow.error.config", detail)) - else ok(KiloBundle.message("toolwindow.row.config")) - } - notifRow.apply { - val detail = errors["notifications"]?.detail ?: KiloBundle.message("toolwindow.error.failed") - if ("notifications" in errors) error(KiloBundle.message("toolwindow.error.notifications", detail)) - else ok(KiloBundle.message("toolwindow.row.notifications")) - } - profileRow.apply { - val detail = errors["profile"]?.detail ?: KiloBundle.message("toolwindow.error.failed") - if ("profile" in errors) error(KiloBundle.message("toolwindow.error.profile", detail)) - else ok(KiloBundle.message("toolwindow.profile.loggedin")) - } - } - } - } - - private fun renderWorkspace(state: KiloWorkspaceStateDto) { - val appReady = controller.model.app.status == KiloAppStatusDto.READY - val visible = appReady || state.status != KiloWorkspaceStatusDto.PENDING - wsSection.isVisible = visible - if (!visible) return - - when (state.status) { - KiloWorkspaceStatusDto.PENDING -> { - providersRow.idle(KiloBundle.message("toolwindow.row.providers")) - agentsRow.idle(KiloBundle.message("toolwindow.row.agents")) - commandsRow.idle(KiloBundle.message("toolwindow.row.commands")) - skillsRow.idle(KiloBundle.message("toolwindow.row.skills")) - } - KiloWorkspaceStatusDto.LOADING -> { - val p = state.progress - if (p != null) { - if (p.providers) providersRow.ok(KiloBundle.message("toolwindow.row.providers")) else providersRow.loading() - if (p.agents) agentsRow.ok(KiloBundle.message("toolwindow.row.agents")) else agentsRow.loading() - if (p.commands) commandsRow.ok(KiloBundle.message("toolwindow.row.commands")) else commandsRow.loading() - if (p.skills) skillsRow.ok(KiloBundle.message("toolwindow.row.skills")) else skillsRow.loading() - } else { - providersRow.loading() - agentsRow.loading() - commandsRow.loading() - skillsRow.loading() - } - } - KiloWorkspaceStatusDto.READY -> { - val prov = state.providers?.providers?.size ?: 0 - val ag = state.agents?.all?.size ?: 0 - val cmd = state.commands.size - val sk = state.skills.size - providersRow.ok(KiloBundle.message("toolwindow.row.providers.count", prov)) - agentsRow.ok(KiloBundle.message("toolwindow.row.agents.count", ag)) - commandsRow.ok(KiloBundle.message("toolwindow.row.commands.count", cmd)) - skillsRow.ok(KiloBundle.message("toolwindow.row.skills.count", sk)) - } - KiloWorkspaceStatusDto.ERROR -> { - val msg = state.error ?: KiloBundle.message("toolwindow.error.unknown") - providersRow.error(msg) - agentsRow.idle(KiloBundle.message("toolwindow.row.agents")) - commandsRow.idle(KiloBundle.message("toolwindow.row.commands")) - skillsRow.idle(KiloBundle.message("toolwindow.row.skills")) - } - } - } - - // ------ helpers ------ - - private fun title(state: KiloAppStateDto): String = - when (state.status) { - KiloAppStatusDto.DISCONNECTED -> KiloBundle.message("toolwindow.status.disconnected") - KiloAppStatusDto.CONNECTING -> KiloBundle.message("toolwindow.status.connecting") - KiloAppStatusDto.LOADING -> KiloBundle.message("toolwindow.status.loading") - KiloAppStatusDto.READY -> { - val ver = controller.model.version - if (ver != null) KiloBundle.message("toolwindow.status.connected.version", ver) - else KiloBundle.message("toolwindow.status.connected") - } - KiloAppStatusDto.ERROR -> KiloBundle.message( - "toolwindow.status.error", - state.error ?: KiloBundle.message("toolwindow.error.unknown"), - ) - } - - private fun renderProfile(profile: ProfileStatusDto) { - when (profile) { - ProfileStatusDto.LOADED -> profileRow.ok(KiloBundle.message("toolwindow.profile.loggedin")) - ProfileStatusDto.NOT_LOGGED_IN -> profileRow.warn(KiloBundle.message("toolwindow.profile.notloggedin")) - ProfileStatusDto.PENDING -> profileRow.loading(KiloBundle.message("toolwindow.row.profile")) - } - } - - private fun resetAll() { - configRow.idle(KiloBundle.message("toolwindow.row.config")) - notifRow.idle(KiloBundle.message("toolwindow.row.notifications")) - profileRow.idle(KiloBundle.message("toolwindow.row.profile")) - providersRow.idle(KiloBundle.message("toolwindow.row.providers")) - agentsRow.idle(KiloBundle.message("toolwindow.row.agents")) - commandsRow.idle(KiloBundle.message("toolwindow.row.commands")) - skillsRow.idle(KiloBundle.message("toolwindow.row.skills")) - } - - // ------ row factory ------ - - private fun row(text: String): StatusRow = StatusRow(text, iconIdle) - - private fun header(text: String): JBLabel = JBLabel(text).apply { - alignmentX = LEFT_ALIGNMENT - font = JBUI.Fonts.label().deriveFont(JBUI.Fonts.label().style or Font.BOLD) - foreground = UIUtil.getLabelForeground() - border = JBUI.Borders.empty(0, 0, 4, 0) - } - - private fun section(hdr: JBLabel, vararg rows: StatusRow): JPanel = JPanel().apply { - layout = BoxLayout(this, BoxLayout.Y_AXIS) - isOpaque = false - alignmentX = CENTER_ALIGNMENT - add(hdr) - for (r in rows) add(r.label) - } - - inner class StatusRow(text: String, icon: Icon) { - val label = JBLabel(text, icon, SwingConstants.LEFT).apply { - font = JBUI.Fonts.label() - foreground = UIUtil.getContextHelpForeground() - iconTextGap = JBUI.scale(6) - border = JBUI.Borders.empty(2, 0) - alignmentX = LEFT_ALIGNMENT - } - - fun ok(msg: String, ic: Icon = iconOk) { - label.icon = ic - label.text = msg - label.foreground = UIUtil.getContextHelpForeground() - } - - fun loading(msg: String = label.text) { - label.icon = iconLoading - label.text = msg - label.foreground = UIUtil.getContextHelpForeground() - } - - fun warn(msg: String) { - label.icon = iconWarn - label.text = msg - label.foreground = UIUtil.getContextHelpForeground() - } - - fun error(msg: String) { - label.icon = iconError - label.text = msg - label.foreground = UIUtil.getErrorForeground() - } - - fun idle(msg: String) { - label.icon = iconIdle - label.text = msg - label.foreground = UIUtil.getContextHelpForeground() - } - } - - override fun dispose() { - // Listener auto-removed by Disposer (registered in init via addListener) - } -} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/DelayedState.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/DelayedState.kt new file mode 100644 index 00000000000..2dba541f6b1 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/DelayedState.kt @@ -0,0 +1,90 @@ +package ai.kilocode.client.session.update + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import javax.swing.Timer + +internal class DelayedState( + private val ms: Long, +) : Disposable { + private val tick = when { + ms <= 0 -> 1 + ms > Int.MAX_VALUE -> Int.MAX_VALUE + else -> ms.coerceAtMost(TICK_MS).toInt() + } + private val timer = Timer(tick) { flush() } + private val pending = mutableListOf>() + @Volatile private var alive = true + + fun run(state: T, current: () -> T, action: (T) -> Unit) { + edt { + if (!alive) return@edt + val next = Pending(state, due(), current, action) + pending.add(next) + if (ms <= 0) { + apply(next) + return@edt + } + timer.start() + } + } + + fun cancel() { + edt { + pending.clear() + timer.stop() + } + } + + internal fun active() = timer.isRunning + + private fun apply(item: Pending) { + if (!alive) return + if (!pending.remove(item)) return + if (item.current() != item.state) return + item.action(item.state) + } + + private fun flush() { + if (!alive) return + val now = System.currentTimeMillis() + for (item in pending.toList()) { + if (item.due > now) continue + apply(item) + } + if (pending.isEmpty()) timer.stop() + } + + private fun due(): Long { + val now = System.currentTimeMillis() + return now + ms.coerceAtMost(Long.MAX_VALUE - now) + } + + private fun edt(block: () -> Unit) { + val app = ApplicationManager.getApplication() + if (app.isDispatchThread) { + block() + return + } + app.invokeLater(block) + } + + override fun dispose() { + alive = false + cancel() + edt { + timer.stop() + } + } + + private data class Pending( + val state: T, + val due: Long, + val current: () -> T, + val action: (T) -> Unit, + ) + + private companion object { + const val TICK_MS = 25L + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt similarity index 70% rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionController.kt rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt index bdc6303385f..80de3621b36 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloSessionService @@ -17,14 +17,17 @@ import ai.kilocode.client.session.model.QuestionItem import ai.kilocode.client.session.model.QuestionOption import ai.kilocode.client.session.model.ToolCallRef import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.ConfigWarningDto import ai.kilocode.rpc.dto.ConfigUpdateDto import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import ai.kilocode.rpc.dto.LoadErrorDto import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto import ai.kilocode.rpc.dto.PermissionReplyDto import ai.kilocode.rpc.dto.PermissionRequestDto import ai.kilocode.rpc.dto.QuestionReplyDto import ai.kilocode.rpc.dto.QuestionRequestDto +import ai.kilocode.rpc.dto.SessionDto import ai.kilocode.rpc.dto.SessionStatusDto import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager @@ -35,6 +38,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.cancel import kotlinx.coroutines.launch +import java.awt.Component /** * Session lifecycle orchestrator for a single session. @@ -49,19 +53,23 @@ import kotlinx.coroutines.launch * via [SessionControllerEvent] to registered listeners. */ class SessionController( - parent: Disposable, - id: String?, - private val sessions: KiloSessionService, - private val workspace: Workspace, - private val app: KiloAppService, - private val cs: CoroutineScope, - comp: java.awt.Component? = null, - private val flushMs: Long = EVENT_FLUSH_MS, - private val condense: Boolean = true, + parent: Disposable, + id: String?, + private val sessions: KiloSessionService, + private val workspace: Workspace, + private val app: KiloAppService, + private val cs: CoroutineScope, + comp: Component? = null, + private val flushMs: Long = EVENT_FLUSH_MS, + private val condense: Boolean = true, + private val displayMs: Long = DISPLAY_DELAY_MS, + private val open: (SessionDto) -> Unit = {}, ) : Disposable { companion object { private val LOG = KiloLog.create(SessionController::class.java) + internal const val RECENT_LIMIT = 5 + internal const val DISPLAY_DELAY_MS = 1_000L } init { @@ -73,22 +81,58 @@ class SessionController( private val listeners = mutableListOf() private var sessionId: String? = id private val directory: String get() = workspace.directory - private val updates = SessionUpdateQueue(parent, comp, flushMs, ::handle, condense, id != null) { sessionId ?: "pending" } + private val updates = SessionUpdateQueue( + parent, + comp, + flushMs, + ::handle, + condense, + id != null + ) { sessionId ?: "pending" } private var partType: String? = null private var tool: String? = null private var eventJob: Job? = null + private var historyState: HistoryState = HistoryState.Idle + private var recentsState: RecentsState = RecentsState.Idle + private var viewState: SessionControllerEvent.ViewChanged? = null + private var connectionState: SessionControllerEvent.ConnectionChanged? = null + private var connectionTargetState: SessionControllerEvent.ConnectionChanged? = null + private val delayedState = DelayedState(displayMs) val ready: Boolean get() = model.isReady() + internal val blank: Boolean get() = sessionId == null && model.isEmpty() && !model.showSession + internal val id: String? get() = sessionId + + fun openSession(session: SessionDto) { + assertEdt() + open(session) + } fun addListener(parent: Disposable, listener: SessionControllerListener) { listeners.add(listener) Disposer.register(parent) { listeners.remove(listener) } } - internal fun flushEvents() = updates.requestFlush(true) + internal fun snapshotState(): ControllerStateSnapshot { + assertEdt() + return ControllerStateSnapshot( + showSession = model.showSession, + viewState = viewState, + connectionState = connectionState, + connectionTargetState = connectionTargetState, + historyState = historyState.toString(), + recentsState = recentsState.toString(), + ) + } + + internal fun flushEvents() { + assertEdt() + updates.requestFlush(true) + } fun prompt(text: String) { + assertEdt() val sid = sessionId ?: "pending" LOG.debug { "${ChatLogSummary.sid(sid)} ${ChatLogSummary.prompt(text)} ${ChatLogSummary.dir(directory)}" } showMessages() @@ -96,7 +140,9 @@ class SessionController( try { val id = sessionId ?: run { val session = sessions.create(directory) - sessionId = session.id + runEdt { + sessionId = session.id + } val meta = if (LOG.isDebugEnabled) ChatLogSummary.dir(directory) else "kind=session" LOG.info("${ChatLogSummary.sid(session.id)} kind=session $meta created=true") subscribeEvents() @@ -115,6 +161,7 @@ class SessionController( } fun abort() { + assertEdt() LOG.debug { "${ChatLogSummary.sid(sessionId ?: "pending")} kind=abort" } val id = sessionId ?: return cs.launch { @@ -127,9 +174,31 @@ class SessionController( } } + fun retryConnection() { + assertEdt() + LOG.debug { + "${ChatLogSummary.sid(sessionId ?: "pending")} kind=connection-retry app=${model.app.status} workspace=${model.workspace.status}" + } + setConnectionTargetState(SessionControllerEvent.ConnectionChanged.ShowConnecting) + setVisibleConnectionState(SessionControllerEvent.ConnectionChanged.ShowConnecting) + // App retry policy is backend-owned and may escalate from lightweight refresh to restart. + if (model.app.status != KiloAppStatusDto.READY || model.app.status == KiloAppStatusDto.ERROR) { + app.retryAsync() + return + } + if (model.app.warnings.isNotEmpty()) { + app.retryAsync() + return + } + // Pure workspace failures stay scoped to workspace reload. + if (model.workspace.status == KiloWorkspaceStatusDto.ERROR) { + workspace.reload() + } + } + fun selectAgent(name: String) { + assertEdt() LOG.debug { "${ChatLogSummary.sid(sessionId ?: "pending")} kind=config agent=$name" } - model.agent = name cs.launch { try { sessions.updateConfig(directory, ConfigUpdateDto(agent = name)) @@ -137,12 +206,14 @@ class SessionController( LOG.warn("${ChatLogSummary.sid(sessionId ?: "pending")} kind=config agent=$name dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) } } - fire(SessionControllerEvent.WorkspaceReady) + fire(SessionControllerEvent.WorkspaceReady) { + model.agent = name + } } fun selectModel(provider: String, id: String) { + assertEdt() LOG.debug { "${ChatLogSummary.sid(sessionId ?: "pending")} kind=config model=$provider/$id" } - model.model = "$provider/$id" cs.launch { try { sessions.updateConfig(directory, ConfigUpdateDto(model = "$provider/$id")) @@ -150,12 +221,15 @@ class SessionController( LOG.warn("${ChatLogSummary.sid(sessionId ?: "pending")} kind=config model=$provider/$id dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) } } - fire(SessionControllerEvent.WorkspaceReady) + fire(SessionControllerEvent.WorkspaceReady) { + model.model = "$provider/$id" + } } // ------ permission / question resolution ------ fun replyPermission(requestId: String, reply: PermissionReplyDto, rules: PermissionAlwaysRulesDto? = null) { + assertEdt() LOG.debug { "${ChatLogSummary.sid(sessionId ?: "pending")} kind=permission rid=$requestId reply=${reply.reply}" } cs.launch { try { @@ -169,6 +243,7 @@ class SessionController( } fun replyQuestion(requestId: String, answers: QuestionReplyDto) { + assertEdt() LOG.debug { "${ChatLogSummary.sid(sessionId ?: "pending")} kind=question rid=$requestId answers=${answers.answers.size}" } cs.launch { try { @@ -181,6 +256,7 @@ class SessionController( } fun rejectQuestion(requestId: String) { + assertEdt() LOG.debug { "${ChatLogSummary.sid(sessionId ?: "pending")} kind=question rid=$requestId rejected=true" } cs.launch { try { @@ -209,6 +285,7 @@ class SessionController( fire(SessionControllerEvent.AppChanged) { model.app = state model.version = app.version + syncConnectionState() } } } @@ -217,6 +294,7 @@ class SessionController( workspace.state.collect { state -> fire(SessionControllerEvent.WorkspaceChanged) { model.workspace = state + syncConnectionState() if (state.status != KiloWorkspaceStatusDto.READY) return@fire @@ -242,6 +320,9 @@ class SessionController( if (state.status == KiloWorkspaceStatusDto.READY) { fire(SessionControllerEvent.WorkspaceReady) + edt { + if (sessionId == null) refreshRecents() + } } } } @@ -250,17 +331,35 @@ class SessionController( private fun loadHistory() { val id = sessionId ?: return cs.launch { + val state = HistoryState.Loading() + runEdt { + setHistoryState(state) + } + delayedState.run(state, { historyState }) { + if (!model.showSession) setControllerViewState(SessionControllerEvent.ViewChanged.ShowProgress) + } try { - val history = sessions.messages(id, directory) - LOG.debug { "${ChatLogSummary.sid(id)} ${ChatLogSummary.history(history)}" } + val items = sessions.messages(id, directory) + LOG.debug { "${ChatLogSummary.sid(id)} ${ChatLogSummary.history(items)}" } runEdt { - this@SessionController.model.loadHistory(history) - if (!model.isEmpty()) showMessages() + this@SessionController.model.loadHistory(items) } recoverPending(id) + edt { + if (!model.isEmpty()) { + showMessages() + return@edt + } + refreshRecents(force = true) + } } catch (e: Exception) { LOG.warn("${ChatLogSummary.sid(id)} kind=history dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) + edt { refreshRecents(force = true) } } finally { + edt { + if (historyState != state) return@edt + setHistoryState(HistoryState.Idle) + } updates.holdFlush(false) updates.requestFlush(true) } @@ -473,9 +572,9 @@ class SessionController( } private fun showMessages() { - if (!model.showMessages) { - model.showMessages = true - fire(SessionControllerEvent.ViewChanged(true)) + assertEdt() + if (!model.showSession) { + setControllerViewState(SessionControllerEvent.ViewChanged.ShowSession) } } @@ -495,6 +594,125 @@ class SessionController( else -> KiloBundle.message("session.status.considering") } + fun refreshRecents(force: Boolean = false) { + assertEdt() + if (model.showSession) return + if (recentsState is RecentsState.Loading) return + if (recentsState is RecentsState.Loaded && !force) return + val state = RecentsState.Loading() + setRecentSessionsState(state) + delayedState.run(state, { recentsState }) { + setControllerViewState(SessionControllerEvent.ViewChanged.ShowProgress) + } + cs.launch { + try { + val items = sessions.recent(directory, RECENT_LIMIT) + edt { + if (recentsState != state) return@edt + setRecentSessionsState(RecentsState.Loaded) + if (model.showSession) return@edt + setControllerViewState(SessionControllerEvent.ViewChanged.ShowRecents(items)) + } + } catch (e: Exception) { + LOG.warn("kind=session-recent dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) + edt { + if (recentsState != state) return@edt + setRecentSessionsState(RecentsState.Loaded) + if (model.showSession) return@edt + setControllerViewState(SessionControllerEvent.ViewChanged.ShowRecents(emptyList())) + } + } + } + } + + private fun setControllerViewState(event: SessionControllerEvent.ViewChanged) { + assertEdt() + if (viewState == event) return + fire(event) { + viewState = event + if (event is SessionControllerEvent.ViewChanged.ShowSession) { + model.showSession = true + setHistoryState(HistoryState.Idle) + setRecentSessionsState(RecentsState.Idle) + } + } + } + + private fun setConnectionTargetState(event: SessionControllerEvent.ConnectionChanged) { + assertEdt() + connectionTargetState = event + val state = event + if (event is SessionControllerEvent.ConnectionChanged.Hide || event is SessionControllerEvent.ConnectionChanged.ShowWarning) { + setVisibleConnectionState(event) + return + } + if (connectionState == event) { + return + } + delayedState.run(event, { if (connectionTargetState == state) state else resolveConnectionState() }, ::setVisibleConnectionState) + } + + private fun setVisibleConnectionState(event: SessionControllerEvent.ConnectionChanged) { + assertEdt() + if (connectionState == event) return + if (connectionState == null && event is SessionControllerEvent.ConnectionChanged.Hide) { + connectionState = event + return + } + fire(event) { + connectionState = event + } + } + + private fun syncConnectionState() { + assertEdt() + setConnectionTargetState(resolveConnectionState()) + } + + private fun setHistoryState(state: HistoryState) { + assertEdt() + historyState = state + } + + private fun setRecentSessionsState(state: RecentsState) { + assertEdt() + recentsState = state + } + + private fun resolveConnectionState(): SessionControllerEvent.ConnectionChanged { + assertEdt() + val app = model.app + val workspace = model.workspace + + if (app.status == KiloAppStatusDto.ERROR) { + return SessionControllerEvent.ConnectionChanged.ShowError( + KiloBundle.message("session.connection.error.app"), + app.errors.toErrorText() ?: app.error, + ) + } + + if (workspace.status == KiloWorkspaceStatusDto.ERROR) { + return SessionControllerEvent.ConnectionChanged.ShowError( + KiloBundle.message("session.connection.error.workspace"), + workspace.errors.toErrorText() ?: workspace.error, + "workspace", + ) + } + + if (app.status == KiloAppStatusDto.READY && workspace.status == KiloWorkspaceStatusDto.READY && app.warnings.isNotEmpty()) { + return SessionControllerEvent.ConnectionChanged.ShowWarning( + summary(app.warnings.size), + app.warnings.toWarningText(), + ) + } + + if (app.status == KiloAppStatusDto.READY && workspace.status == KiloWorkspaceStatusDto.READY) { + return SessionControllerEvent.ConnectionChanged.Hide + } + + return SessionControllerEvent.ConnectionChanged.ShowConnecting + } + private fun fire(event: SessionControllerEvent, before: (() -> Unit)? = null) { LOG.debug { "session=$sessionId controller: $event" } val application = ApplicationManager.getApplication() @@ -509,6 +727,10 @@ class SessionController( } } + private fun assertEdt() { + check(ApplicationManager.getApplication().isDispatchThread) { "SessionController state must be accessed on EDT" } + } + private fun edt(block: () -> Unit) { ApplicationManager.getApplication().invokeLater(block) } @@ -523,6 +745,7 @@ class SessionController( } override fun dispose() { + delayedState.dispose() eventJob?.cancel() cs.cancel() } @@ -595,6 +818,56 @@ private fun matchesSession(event: ChatEventDto, id: String): Boolean = when (eve is ChatEventDto.TodoUpdated -> event.sessionID == id } +private fun summary(count: Int): String { + val base = KiloBundle.message("session.connection.warning.config") + if (count <= 1) return base + return "$base ($count)" +} + +private sealed interface RecentsState { + data object Idle : RecentsState + data class Loading(val id: Any = Any()) : RecentsState + data object Loaded : RecentsState +} + +private sealed interface HistoryState { + data object Idle : HistoryState + data class Loading(val id: Any = Any()) : HistoryState +} + +internal data class ControllerStateSnapshot( + val showSession: Boolean, + val viewState: SessionControllerEvent.ViewChanged?, + val connectionState: SessionControllerEvent.ConnectionChanged?, + val connectionTargetState: SessionControllerEvent.ConnectionChanged?, + val historyState: String, + val recentsState: String, +) + +private fun List.toErrorText(): String? { + val out = mapNotNull { it.toDetailLine() } + if (out.isEmpty()) return null + return out.joinToString("\n") +} + +private fun List.toWarningText(): String? { + val out = mapNotNull { it.toDetailLine() } + if (out.isEmpty()) return null + return out.joinToString("\n\n") +} + +private fun LoadErrorDto.toDetailLine(): String? { + val detail = detail?.trim()?.ifEmpty { null } ?: return null + if (resource == "connection") return detail + return "$resource: $detail" +} + +private fun ConfigWarningDto.toDetailLine(): String { + val head = "$path: $message" + val tail = detail?.trim()?.ifEmpty { null } ?: return head + return "$head\n$tail" +} + private fun toPermission(dto: PermissionRequestDto): Permission { val ref = dto.tool?.let { ToolCallRef(it.messageID, it.callID) } val file = dto.metadata["file"] ?: dto.metadata["path"] diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionControllerEvent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionControllerEvent.kt new file mode 100644 index 00000000000..121af83b5d8 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionControllerEvent.kt @@ -0,0 +1,62 @@ +package ai.kilocode.client.session.update + +import ai.kilocode.client.session.model.SessionModel +import ai.kilocode.client.session.model.SessionModelEvent +import ai.kilocode.rpc.dto.SessionDto + +/** + * Lifecycle events fired by [SessionController] on the EDT. + * + * These cover app/workspace state changes and view switching — things + * outside the [SessionModel] domain. For model mutations (messages, + * parts, state), listen to [SessionModelEvent] on [SessionModel] directly. + */ +sealed class SessionControllerEvent { + + // App + workspace lifecycle (every state transition) + data object AppChanged : SessionControllerEvent() + data object WorkspaceChanged : SessionControllerEvent() + + // Workspace ready (pickers populated) + data object WorkspaceReady : SessionControllerEvent() + + sealed class ViewChanged : SessionControllerEvent() { + data object ShowProgress : ViewChanged() { + override fun toString() = "ViewChanged progress" + } + + data class ShowRecents(val recents: List) : ViewChanged() { + override fun toString() = "ViewChanged recents=${recents.size}" + } + + data object ShowSession : ViewChanged() { + override fun toString() = "ViewChanged session" + } + } + + sealed class ConnectionChanged : SessionControllerEvent() { + data object Hide : ConnectionChanged() { + override fun toString() = "ConnectionChanged hide" + } + + data object ShowConnecting : ConnectionChanged() { + override fun toString() = "ConnectionChanged connecting" + } + + data class ShowError(val summary: String, val detail: String?, val source: String = "app") : ConnectionChanged() { + override fun toString() = "ConnectionChanged error $source" + } + + data class ShowWarning(val summary: String, val detail: String?) : ConnectionChanged() { + override fun toString() = "ConnectionChanged warning" + } + } +} + +/** + * Listener for [SessionControllerEvent]s fired by [SessionController]. + * All callbacks are guaranteed to run on the EDT. + */ +fun interface SessionControllerListener { + fun onEvent(event: SessionControllerEvent) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionQueueCondenser.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionQueueCondenser.kt similarity index 96% rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionQueueCondenser.kt rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionQueueCondenser.kt index cb6b85d7667..dcb06f4e6ca 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionQueueCondenser.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionQueueCondenser.kt @@ -1,9 +1,9 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update import ai.kilocode.rpc.dto.ChatEventDto /** - * Reduces a batch of queued [ChatEventDto] events before they are flushed to + * Reduces a batch of queued [ai.kilocode.rpc.dto.ChatEventDto] events before they are flushed to * the model, by merging consecutive same-key snapshot and text-delta events. * * ## Algorithm diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUpdateQueue.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionUpdateQueue.kt similarity index 99% rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUpdateQueue.kt rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionUpdateQueue.kt index 2c02e7b3349..5881e1bff08 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUpdateQueue.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionUpdateQueue.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update import ai.kilocode.log.ChatLogSummary import ai.kilocode.log.KiloLog diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/plus.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/plus.svg new file mode 100644 index 00000000000..84f0a44ca51 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/plus.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/plus_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/plus_dark.svg new file mode 100644 index 00000000000..84f0a44ca51 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/plus_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml index 5922e16ebd1..d865deb63fe 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml +++ b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml @@ -31,14 +31,9 @@ - - - - + if (SessionManager.KEY.`is`(id)) manager else null + } + return AnActionEvent.createFromDataContext("", presentation, context) + } + + private class FakeManager : SessionManager { + var created = 0 + override fun newSession() { + created++ + } + + override fun openSession(session: SessionDto) { + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/AppWatchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/AppWatchingTest.kt deleted file mode 100644 index 157642dc922..00000000000 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/AppWatchingTest.kt +++ /dev/null @@ -1,26 +0,0 @@ -package ai.kilocode.client.session - -import ai.kilocode.rpc.dto.KiloAppStateDto -import ai.kilocode.rpc.dto.KiloAppStatusDto - -class AppWatchingTest : SessionControllerTestBase() { - - fun `test app state change fires AppChanged`() { - val m = controller() - val events = collect(m) - flush() - events.clear() - - appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) - flush() - - assertControllerEvents("AppChanged", events) - assertSession( - """ - [app: READY] [workspace: PENDING] - """, - m, - show = false, - ) - } -} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt new file mode 100644 index 00000000000..e2525228094 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt @@ -0,0 +1,204 @@ +package ai.kilocode.client.session + +import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloSessionService +import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.app.Workspace +import ai.kilocode.client.testing.FakeAppRpcApi +import ai.kilocode.client.testing.FakeSessionRpcApi +import ai.kilocode.client.testing.FakeWorkspaceRpcApi +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.KiloWorkspaceStateDto +import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import ai.kilocode.rpc.dto.SessionDto +import ai.kilocode.rpc.dto.SessionTimeDto +import com.intellij.openapi.actionSystem.DataProvider +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import javax.swing.JPanel + +@Suppress("UnstableApiUsage") +class SessionSidePanelManagerTest : BasePlatformTestCase() { + private lateinit var scope: CoroutineScope + private lateinit var rpc: FakeSessionRpcApi + private lateinit var workspaces: KiloWorkspaceService + private lateinit var workspace: Workspace + private lateinit var sessions: KiloSessionService + private lateinit var app: KiloAppService + private val managers = mutableListOf() + private val created = mutableListOf>() + private val loading = mutableListOf() + private val ui = mutableListOf() + + override fun setUp() { + super.setUp() + scope = CoroutineScope(SupervisorJob()) + rpc = FakeSessionRpcApi() + sessions = KiloSessionService(project, scope, rpc) + app = KiloAppService(scope, FakeAppRpcApi().also { + it.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + }) + workspaces = KiloWorkspaceService(scope, FakeWorkspaceRpcApi().also { + it.state.value = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.READY) + }) + workspace = workspaces.workspace("/test") + } + + override fun tearDown() { + try { + managers.forEach { Disposer.dispose(it) } + scope.cancel() + } finally { + super.tearDown() + } + } + + fun `test component provides session manager`() { + val manager = manager() + val provider = manager.component as DataProvider + + assertSame(manager, provider.getData(SessionManager.KEY.name)) + } + + fun `test new session replaces active component`() { + val manager = manager() + + manager.newSession() + val first = active(manager) + com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait { + first.controller().prompt("hello") + } + settle() + manager.newSession() + val second = active(manager) + + assertNotSame(first, second) + assertEquals(listOf("/test" to null, "/test" to null), created) + assertEquals(listOf(true, false), loading) + } + + fun `test new session on blank session keeps active component`() { + val manager = manager() + + manager.newSession() + val first = active(manager) + manager.newSession() + val second = active(manager) + + assertSame(first, second) + assertEquals(listOf("/test" to null), created) + assertEquals(listOf(true), loading) + } + + fun `test opening same existing session reuses component`() { + val manager = manager() + val session = session("ses_1") + + manager.openSession(session) + val first = active(manager) + manager.newSession() + manager.openSession(session) + val second = active(manager) + + assertSame(first, second) + assertEquals(listOf("/test" to "ses_1", "/test" to null), created) + assertEquals(listOf(false, false), loading) + } + + fun `test prompted blank session is reused from recents`() { + val manager = manager() + manager.newSession() + val first = active(manager) + + com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait { + first.controller().prompt("hello") + } + settle() + manager.newSession() + manager.openSession(session("ses_test")) + val second = active(manager) + + assertSame(first, second) + assertEquals(1, rpc.creates) + assertEquals(listOf("/test" to null, "/test" to null), created) + } + + fun `test anonymous blank session is disposed when replaced`() { + val manager = manager() + manager.newSession() + val first = active(manager) + + manager.openSession(session("ses_1")) + + assertNotSame(first, active(manager)) + assertFalse(ui.contains(first)) + } + + fun `test open session resolves historical workspace`() { + val manager = manager() + + manager.openSession(session("ses_1", "/repo")) + + assertEquals(listOf("/repo" to "ses_1"), created) + assertEquals(listOf(false), loading) + } + + fun `test dispose removes active component`() { + val manager = manager() + + manager.newSession() + Disposer.dispose(manager) + managers.remove(manager) + + assertEquals(0, manager.component.componentCount) + } + + private fun manager(): SessionSidePanelManager { + val manager = SessionSidePanelManager( + project = project, + root = workspace, + create = { project, workspace, owner, id, show -> + created.add(workspace.directory to id) + loading.add(show) + SessionUi(project, workspace, sessions, app, scope, id = id, loading = show, open = owner::openSession).also { + ui.add(it) + Disposer.register(it) { ui.remove(it) } + } + }, + resolve = { workspaces.workspace(it) }, + ) + managers.add(manager) + return manager + } + + private fun active(manager: SessionSidePanelManager) = manager.component.getComponent(0) as JPanel + + private fun JPanel.controller(): ai.kilocode.client.session.update.SessionController { + val field = SessionUi::class.java.getDeclaredField("controller") + field.isAccessible = true + return field.get(this) as ai.kilocode.client.session.update.SessionController + } + + private fun settle() = kotlinx.coroutines.runBlocking { + repeat(5) { + kotlinx.coroutines.delay(100) + com.intellij.util.ui.UIUtil.dispatchAllInvocationEvents() + } + } + + private fun session(id: String) = session(id, "/test") + + private fun session(id: String, dir: String) = SessionDto( + id = id, + projectID = "prj", + directory = dir, + title = "Session $id", + version = "1", + time = SessionTimeDto(created = 1.0, updated = 2.0), + ) + +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt new file mode 100644 index 00000000000..cad1b00915c --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt @@ -0,0 +1,111 @@ +package ai.kilocode.client.session + +import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloSessionService +import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.app.Workspace +import ai.kilocode.client.testing.FakeAppRpcApi +import ai.kilocode.client.testing.FakeSessionRpcApi +import ai.kilocode.client.testing.FakeWorkspaceRpcApi +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.KiloWorkspaceStateDto +import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import ai.kilocode.rpc.dto.SessionDto +import ai.kilocode.rpc.dto.SessionTimeDto +import com.intellij.openapi.components.service +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel + +@Suppress("UnstableApiUsage") +class SessionUiFactoryTest : BasePlatformTestCase() { + private lateinit var scope: CoroutineScope + private lateinit var workspace: Workspace + private lateinit var sessions: KiloSessionService + private lateinit var app: KiloAppService + + override fun setUp() { + super.setUp() + scope = CoroutineScope(SupervisorJob()) + sessions = KiloSessionService(project, scope, FakeSessionRpcApi()) + app = KiloAppService(scope, FakeAppRpcApi().also { + it.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + }) + val workspaces = KiloWorkspaceService(scope, FakeWorkspaceRpcApi().also { + it.state.value = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.READY) + }) + workspace = workspaces.workspace("/test") + } + + override fun tearDown() { + try { + scope.cancel() + } finally { + super.tearDown() + } + } + + fun `test factory creates blank session ui`() { + val ui = direct().create(project, workspace, FakeManager(), null, true) + + assertNotNull(ui) + } + + fun `test factory wires open callback`() { + val manager = FakeManager() + val rpc = session("ses_1") + val ui = SessionUi(project, workspace, sessions, app, scope, open = manager::openSession) + val controller = controller(ui) + + com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait { + controller.openSession(rpc) + } + + assertEquals(listOf("ses_1"), manager.opened) + } + + fun `test empty panel opens through controller`() { + val manager = FakeManager() + val rpc = session("ses_1") + val ui = SessionUi(project, workspace, sessions, app, scope, open = manager::openSession) + val controller = controller(ui) + val panel = ai.kilocode.client.session.ui.EmptySessionPanel(testRootDisposable, controller, listOf(rpc)) + + panel.clickRecent(0) + + assertEquals(listOf("ses_1"), manager.opened) + } + + private fun controller(ui: SessionUi): ai.kilocode.client.session.update.SessionController { + val field = SessionUi::class.java.getDeclaredField("controller") + field.isAccessible = true + return field.get(ui) as ai.kilocode.client.session.update.SessionController + } + + fun `test application service is available`() { + assertNotNull(service()) + } + + private fun direct() = SessionUiFactory(scope) + + private fun session(id: String) = SessionDto( + id = id, + projectID = "prj", + directory = "/test", + title = "Session $id", + version = "1", + time = SessionTimeDto(created = 1.0, updated = 2.0), + ) + + private class FakeManager : SessionManager { + val opened = mutableListOf() + override fun newSession() { + } + + override fun openSession(session: SessionDto) { + opened.add(session.id) + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt new file mode 100644 index 00000000000..4602afa07fa --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt @@ -0,0 +1,339 @@ +package ai.kilocode.client.session + +import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloSessionService +import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.app.Workspace +import ai.kilocode.client.session.model.Permission +import ai.kilocode.client.session.model.PermissionMeta +import ai.kilocode.client.session.model.Question +import ai.kilocode.client.session.model.QuestionItem +import ai.kilocode.client.session.model.QuestionOption +import ai.kilocode.client.session.model.SessionState +import ai.kilocode.client.session.ui.ConnectionPanel +import ai.kilocode.client.session.ui.EmptySessionPanel +import ai.kilocode.client.session.ui.PermissionPanel +import ai.kilocode.client.session.ui.PromptPanel +import ai.kilocode.client.session.ui.QuestionPanel +import ai.kilocode.client.session.ui.SessionMessageListPanel +import ai.kilocode.client.session.ui.SessionRootPanel +import ai.kilocode.client.session.update.SessionController +import ai.kilocode.client.session.update.SessionControllerEvent +import ai.kilocode.client.testing.FakeAppRpcApi +import ai.kilocode.client.testing.FakeSessionRpcApi +import ai.kilocode.client.testing.FakeWorkspaceRpcApi +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.KiloWorkspaceStateDto +import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import ai.kilocode.rpc.dto.MessageDto +import ai.kilocode.rpc.dto.MessageTimeDto +import ai.kilocode.rpc.dto.MessageWithPartsDto +import ai.kilocode.rpc.dto.SessionDto +import ai.kilocode.rpc.dto.SessionTimeDto +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.JBScrollPane +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import javax.swing.JLayeredPane + +@Suppress("UnstableApiUsage") +class SessionUiLayoutTest : BasePlatformTestCase() { + + private lateinit var scope: CoroutineScope + private lateinit var sessions: KiloSessionService + private lateinit var app: KiloAppService + private lateinit var workspaces: KiloWorkspaceService + private lateinit var rpc: FakeSessionRpcApi + private lateinit var workspace: Workspace + private lateinit var ui: SessionUi + + override fun setUp() { + super.setUp() + scope = CoroutineScope(SupervisorJob()) + + rpc = FakeSessionRpcApi() + val appRpc = FakeAppRpcApi().also { + it.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + } + val workspaceRpc = FakeWorkspaceRpcApi().also { + it.state.value = KiloWorkspaceStateDto(status = KiloWorkspaceStatusDto.READY) + } + + sessions = KiloSessionService(project, scope, rpc) + app = KiloAppService(scope, appRpc) + workspaces = KiloWorkspaceService(scope, workspaceRpc) + workspace = workspaces.workspace("/test") + + ui = SessionUi(project, workspace, sessions, app, scope, displayMs = 0).apply { + setSize(800, 600) + } + layout() + } + + override fun tearDown() { + try { + scope.cancel() + } finally { + super.tearDown() + } + } + + fun `test root contains content and overlay layers`() { + val root = find(ui) + + assertEquals(2, root.componentCount) + assertSame(root.content, root.components.first { it === root.content }) + assertSame(root.overlay, root.components.first { it === root.overlay }) + assertEquals(JLayeredPane.DEFAULT_LAYER, root.getLayer(root.content)) + assertEquals(JLayeredPane.PALETTE_LAYER, root.getLayer(root.overlay)) + } + + fun `test connection panel is docked between permission and prompt`() { + val root = find(ui) + val question = find(ui) + val permission = find(ui) + val connection = find(ui) + val prompt = find(ui) + val stack = prompt.parent + + assertSame(root.content, stack.parent) + assertSame(stack, connection.parent) + assertEquals(0, root.overlay.componentCount) + assertEquals(listOf(question, permission, connection, prompt), stack.components.toList()) + } + + fun `test connection panel uses stack width and sits above prompt`() { + val connection = find(ui) + val prompt = find(ui) + val stack = prompt.parent + + showConnection() + layout() + + assertTrue(connection.isVisible) + assertEquals(0, connection.x) + assertEquals(stack.width, connection.width) + assertEquals(prompt.width, connection.width) + assertTrue(connection.y + connection.height <= prompt.y) + } + + fun `test connection panel moves after visible question panel`() { + val connection = find(ui) + val question = find(ui) + val prompt = find(ui) + + showConnection() + layout() + assertFalse(question.isVisible) + val top = connection.y + + controller().model.setState(questionStateChanged()) + layout() + + assertTrue(question.isVisible) + assertTrue(question.y < connection.y) + assertTrue(top < connection.y) + assertTrue(connection.y + connection.height <= prompt.y) + } + + fun `test connection panel moves after visible permission panel`() { + val connection = find(ui) + val permission = find(ui) + val prompt = find(ui) + + showConnection() + layout() + assertFalse(permission.isVisible) + val top = connection.y + + controller().model.setState(permissionStateChanged()) + layout() + + assertTrue(permission.isVisible) + assertTrue(permission.y < connection.y) + assertTrue(top < connection.y) + assertTrue(connection.y + connection.height <= prompt.y) + } + + fun `test empty and message bodies share the same scroll pane`() { + settle() + val scroll = find(ui) + val empty = find(ui) + + assertSame(empty, scroll.viewport.view) + + com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait { + controller().prompt("hello") + } + layout() + + assertSame(scroll, find(ui).parent.parent) + assertSame(find(ui), scroll.viewport.view) + } + + fun `test new session starts with loading body`() { + ui = SessionUi(project, workspace, sessions, app, scope, displayMs = 1_000).apply { + setSize(800, 600) + } + + assertFalse(find(ui).viewport.view is EmptySessionPanel) + } + + fun `test action-created new session starts blank`() { + ui = SessionUi(project, workspace, sessions, app, scope, displayMs = 1_000, loading = false).apply { + setSize(800, 600) + } + + assertFalse(find(ui).viewport.view is EmptySessionPanel) + assertFalse(find(ui).viewport.view is SessionMessageListPanel) + } + + fun `test clicking recent session calls opener`() { + val opened = mutableListOf() + rpc.recent.add(session("ses_1")) + ui = SessionUi(project, workspace, sessions, app, scope, displayMs = 0, open = { opened.add(it.id) }).apply { + setSize(800, 600) + } + + settle() + layout() + find(ui).clickRecent(0) + + assertEquals(listOf("ses_1"), opened) + } + + fun `test existing session id loads history and shows message body`() { + rpc.history.add(MessageWithPartsDto(message("msg1"), emptyList())) + + ui = SessionUi(project, workspace, sessions, app, scope, id = "ses_test", displayMs = 0).apply { + setSize(800, 600) + } + settle() + + assertSame(find(ui), find(ui).viewport.view) + } + + fun `test new session keeps loading body before recents delay`() { + rpc.recentGate = kotlinx.coroutines.CompletableDeferred() + ui = SessionUi(project, workspace, sessions, app, scope, displayMs = 1_000).apply { + setSize(800, 600) + } + + settleShort(100) + + assertFalse(find(ui).viewport.view is EmptySessionPanel) + } + + fun `test slow recents switch to loading body only after progress event`() { + rpc.recentGate = kotlinx.coroutines.CompletableDeferred() + rpc.recent.add(session("ses_1")) + ui = SessionUi(project, workspace, sessions, app, scope, displayMs = 50).apply { + setSize(800, 600) + } + + settleShort(20) + assertFalse(find(ui).viewport.view is EmptySessionPanel) + + settleShort(80) + assertFalse(find(ui).viewport.view is EmptySessionPanel) + + rpc.recentGate!!.complete(Unit) + settle() + + val panel = find(ui) + assertSame(panel, find(ui).viewport.view) + assertEquals(1, panel.recentCount()) + } + + private fun layout() { + ui.doLayout() + val root = find(ui) + root.doLayout() + root.content.doLayout() + find(ui).parent.doLayout() + } + + private fun settle() = runBlocking { + repeat(5) { + delay(100) + com.intellij.util.ui.UIUtil.dispatchAllInvocationEvents() + } + } + + private fun settleShort(ms: Long) = runBlocking { + delay(ms) + com.intellij.util.ui.UIUtil.dispatchAllInvocationEvents() + } + + private fun showConnection() { + find(ui).onEvent(SessionControllerEvent.ConnectionChanged.ShowConnecting) + } + + private inline fun find(root: java.awt.Container): T { + return find(root, T::class.java) ?: error("missing ${T::class.java.simpleName}") + } + + private fun find(root: java.awt.Container, cls: Class): T? { + if (cls.isInstance(root)) return cls.cast(root) + for (child in root.components) { + if (cls.isInstance(child)) return cls.cast(child) + if (child is java.awt.Container) { + val item = find(child, cls) + if (item != null) return item + } + } + return null + } + + private fun controller(): SessionController { + val field = SessionUi::class.java.getDeclaredField("controller") + field.isAccessible = true + return field.get(ui) as SessionController + } + + private fun questionStateChanged() = SessionState.AwaitingQuestion( + Question( + id = "q1", + items = listOf( + QuestionItem( + question = "Proceed?", + header = "Confirm", + options = listOf(QuestionOption("Yes", "Continue")), + multiple = false, + custom = true, + ) + ), + ) + ) + + private fun permissionStateChanged() = SessionState.AwaitingPermission( + Permission( + id = "p1", + sessionId = "ses", + name = "edit", + patterns = listOf("*.kt"), + always = emptyList(), + meta = PermissionMeta(raw = emptyMap()), + ) + ) + + private fun session(id: String) = SessionDto( + id = id, + projectID = "prj", + directory = "/test", + title = "Recent $id", + version = "1", + time = SessionTimeDto(created = 1.0, updated = 2.0), + ) + + private fun message(id: String) = MessageDto( + id = id, + sessionID = "ses_test", + role = "user", + time = MessageTimeDto(created = 0.0), + ) +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ViewSwitchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ViewSwitchingTest.kt deleted file mode 100644 index 66f687eb884..00000000000 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ViewSwitchingTest.kt +++ /dev/null @@ -1,36 +0,0 @@ -package ai.kilocode.client.session - -class ViewSwitchingTest : SessionControllerTestBase() { - - fun `test first prompt shows messages view`() { - val m = controller() - val events = collect(m) - flush() - events.clear() - - edt { m.prompt("hello") } - flush() - - assertControllerEvents("ViewChanged show", events) - assertSession( - """ - [app: DISCONNECTED] [workspace: PENDING] - """, - m, - ) - } - - fun `test ViewChanged not fired twice`() { - val m = controller() - val events = collect(m) - flush() - events.clear() - - edt { m.prompt("first") } - flush() - edt { m.prompt("second") } - flush() - - assertControllerEvents("ViewChanged show", events) - } -} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt new file mode 100644 index 00000000000..772275be866 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt @@ -0,0 +1,163 @@ +package ai.kilocode.client.session.ui + +import ai.kilocode.client.session.update.SessionController +import ai.kilocode.client.session.update.SessionControllerEvent +import ai.kilocode.client.session.update.SessionControllerTestBase +import ai.kilocode.rpc.dto.ConfigWarningDto +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import com.intellij.util.ui.UIUtil +import java.awt.Dimension + +@Suppress("UnstableApiUsage") +class ConnectionPanelTest : SessionControllerTestBase() { + + private lateinit var panel: ConnectionPanel + private lateinit var controller: SessionController + + override fun setUp() { + super.setUp() + controller = controller("ses_test") + panel = ConnectionPanel(parent, controller) + flush() + } + + fun `test loading hides retry and details`() { + edt { + panel.onEvent(SessionControllerEvent.ConnectionChanged.ShowConnecting) + } + + assertTrue(panel.isVisible) + assertEquals("Loading...", panel.summaryText()) + assertEquals("", panel.detailsText()) + assertFalse(panel.toggleVisible()) + assertFalse(panel.detailsVisible()) + assertFalse(panel.retryVisible()) + } + + fun `test app error starts collapsed and expands details`() { + edt { + panel.onEvent(SessionControllerEvent.ConnectionChanged.ShowError( + "CLI startup failed", + "stderr line\nconfig: HTTP 500: broken", + )) + } + + assertTrue(panel.isVisible) + assertEquals("CLI startup failed", panel.summaryText()) + assertEquals(UIUtil.getErrorForeground(), panel.summaryColor()) + assertTrue(panel.toggleVisible()) + assertFalse(panel.toggleExpanded()) + assertFalse(panel.detailsVisible()) + assertEquals("stderr line\nconfig: HTTP 500: broken", panel.detailsText()) + assertEquals(UIUtil.getLabelForeground(), panel.detailsColor()) + assertTrue(panel.retryVisible()) + assertFalse(panel.retryFocusable()) + + edt { panel.clickSummary() } + + assertTrue(panel.toggleExpanded()) + assertTrue(panel.detailsVisible()) + + edt { panel.clickToggle() } + + assertFalse(panel.toggleExpanded()) + assertFalse(panel.detailsVisible()) + } + + fun `test workspace error shows retry without details`() { + edt { + panel.onEvent(SessionControllerEvent.ConnectionChanged.ShowError("Workspace failed", null)) + } + + assertTrue(panel.isVisible) + assertEquals("Workspace failed", panel.summaryText()) + assertFalse(panel.toggleVisible()) + assertFalse(panel.detailsVisible()) + assertEquals("", panel.detailsText()) + assertTrue(panel.retryVisible()) + assertEquals("Try again", panel.retryText()) + } + + fun `test retry click triggers app retry for app error`() { + edt { + controller.model.app = KiloAppStateDto( + status = KiloAppStatusDto.ERROR, + error = "CLI startup failed", + ) + panel.onEvent(SessionControllerEvent.ConnectionChanged.ShowError("CLI startup failed", null)) + } + edt { panel.clickRetry() } + flush() + + assertEquals(1, appRpc.retries) + } + + fun `test ready warnings show collapsed banner with retry`() { + edt { + panel.onEvent(SessionControllerEvent.ConnectionChanged.ShowWarning( + "Configuration warnings", + ".kilo/kilo.json: Invalid JSON\nCloseBraceExpected at line 11, column 1", + )) + } + + assertTrue(panel.isVisible) + assertEquals("Configuration warnings", panel.summaryText()) + assertNotSame(UIUtil.getContextHelpForeground(), panel.summaryColor()) + assertTrue(panel.toggleVisible()) + assertFalse(panel.toggleExpanded()) + assertFalse(panel.detailsVisible()) + assertTrue(panel.retryVisible()) + assertFalse(panel.retryFocusable()) + assertEquals( + ".kilo/kilo.json: Invalid JSON\nCloseBraceExpected at line 11, column 1", + panel.detailsText(), + ) + + edt { panel.clickSummary() } + + assertTrue(panel.toggleExpanded()) + assertTrue(panel.detailsVisible()) + } + + fun `test retry click triggers app retry for warnings`() { + edt { + controller.model.app = KiloAppStateDto( + status = KiloAppStatusDto.READY, + warnings = listOf(ConfigWarningDto(path = ".kilo/kilo.json", message = "Invalid JSON")), + ) + panel.onEvent(SessionControllerEvent.ConnectionChanged.ShowWarning("Configuration warnings", null)) + } + edt { panel.clickRetry() } + flush() + + assertEquals(1, appRpc.retries) + } + + fun `test expanded details height is capped at ten lines`() { + edt { + panel.onEvent(SessionControllerEvent.ConnectionChanged.ShowError("CLI startup failed", lines(30))) + panel.size = Dimension(480, 1000) + } + + edt { panel.clickSummary() } + + assertTrue(panel.detailsVisible()) + assertTrue(panel.preferredSize.height <= panel.maxExpandedHeight()) + } + + fun `test raw app and workspace events do not render panel`() { + edt { + panel.onEvent(SessionControllerEvent.AppChanged) + panel.onEvent(SessionControllerEvent.WorkspaceChanged) + } + + assertFalse(panel.isVisible) + } + + fun `test panel has top separator`() { + assertTrue(panel.hasSeparator()) + } + + private fun lines(count: Int) = (1..count).joinToString("\n") { "line $it" } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt new file mode 100644 index 00000000000..50ee03c03af --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt @@ -0,0 +1,168 @@ +package ai.kilocode.client.session.ui + +import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloSessionService +import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.app.Workspace +import ai.kilocode.client.session.update.SessionController +import ai.kilocode.client.testing.FakeAppRpcApi +import ai.kilocode.client.testing.FakeSessionRpcApi +import ai.kilocode.client.testing.FakeWorkspaceRpcApi +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.KiloWorkspaceStateDto +import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import ai.kilocode.rpc.dto.SessionDto +import ai.kilocode.rpc.dto.SessionTimeDto +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import java.awt.BorderLayout +import javax.swing.JPanel + +@Suppress("UnstableApiUsage") +class EmptySessionPanelTest : BasePlatformTestCase() { + private lateinit var scope: CoroutineScope + private lateinit var app: KiloAppService + private lateinit var workspace: Workspace + private lateinit var controller: SessionController + private val opened = mutableListOf() + + override fun setUp() { + super.setUp() + scope = CoroutineScope(SupervisorJob()) + app = KiloAppService(scope, FakeAppRpcApi().also { + it.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + }) + val workspaces = KiloWorkspaceService(scope, FakeWorkspaceRpcApi().also { + it.state.value = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.READY) + }) + workspace = workspaces.workspace("/test") + controller = SessionController( + parent = testRootDisposable, + id = null, + sessions = KiloSessionService(project, scope, FakeSessionRpcApi()), + workspace = workspace, + app = app, + cs = scope, + open = { opened.add(it.id) }, + ) + } + + override fun tearDown() { + try { + scope.cancel() + } finally { + super.tearDown() + } + } + + fun `test content is initialized immediately`() { + val panel = panel() + + assertTrue(panel.initialized()) + assertFalse(panel.loadingVisible()) + } + + fun `test recent section remains visible when empty`() { + val panel = panel() + + assertTrue(panel.recentVisible()) + assertEquals(0, panel.recentCount()) + } + + fun `test empty state has visible preferred height`() { + val panel = panel() + + assertTrue(panel.preferredSize.height > 0) + } + + fun `test content has fixed preferred width`() { + val panel = panel() + + assertEquals(com.intellij.util.ui.JBUI.scale(EmptySessionPanel.MAX_WIDTH), panel.contentPreferredSize().width) + assertTrue(panel.contentPreferredSize().height > 0) + } + + fun `test recent sessions are capped at five`() { + val panel = panel((1..7).map { session("ses_$it") }) + + assertTrue(panel.recentVisible()) + assertEquals(5, panel.recentCount()) + } + + fun `test explanation uses markdown view`() { + val panel = panel() + + assertEquals( + "Kilo Code is an AI coding assistant. Ask it to build features, fix bugs, or explain your codebase.", + panel.explanationMarkdown(), + ) + } + + fun `test selecting recent session does not open it`() { + val panel = panel(listOf(session("ses_1"), session("ses_2"))) + + panel.selectRecent(1) + + assertEquals(1, panel.selectedRecent()) + assertEquals(emptyList(), opened) + } + + fun `test clicking recent session delegates to controller`() { + val panel = panel(listOf(session("ses_1"), session("ses_2"))) + + panel.clickRecent(1) + + assertEquals(listOf("ses_2"), opened) + } + + fun `test renderer aligns title center and time east`() { + val cell = panel().rendererComponent(session("ses_1")) as JPanel + val layout = cell.layout as BorderLayout + + assertNotNull(layout.getLayoutComponent(BorderLayout.CENTER)) + assertNotNull(layout.getLayoutComponent(BorderLayout.EAST)) + } + + fun `test hover uses selection colors`() { + val panel = panel() + val session = session("ses_1") + val selected = panel.rendererComponent(session, selected = true) as JPanel + val hovered = panel.rendererComponent(session, hover = true) as JPanel + + assertTrue(selected.isOpaque) + assertTrue(hovered.isOpaque) + assertEquals(selected.background, hovered.background) + } + + fun `test timestamp normalization handles seconds and milliseconds`() { + val panel = panel() + + assertEquals(1_700_000_000_000L, panel.normalize(1_700_000_000.0)) + assertEquals(1_700_000_000_000L, panel.normalize(1_700_000_000_000.0)) + } + + fun `test timestamp renders coarse relative text`() { + val panel = panel() + val now = 1_700_000_000_000L + + assertEquals("Moments ago", panel.text(session("ses_1", now - 30_000), now)) + assertEquals("2 min ago", panel.text(session("ses_1", now - 120_000), now)) + assertEquals("3h ago", panel.text(session("ses_1", now - 10_800_000), now)) + assertEquals("4d ago", panel.text(session("ses_1", now - 345_600_000), now)) + } + + private fun panel(recents: List = emptyList()) = + EmptySessionPanel(testRootDisposable, controller, recents) + + private fun session(id: String, updated: Long = 2_000L) = SessionDto( + id = id, + projectID = "prj", + directory = "/repo/$id", + title = "Title $id", + version = "1", + time = SessionTimeDto(created = 1.0, updated = updated.toDouble()), + ) +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ProgressPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ProgressPanelTest.kt similarity index 96% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ProgressPanelTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ProgressPanelTest.kt index 2162a5df508..271a89e5617 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ProgressPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ProgressPanelTest.kt @@ -1,10 +1,9 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.ui import ai.kilocode.client.session.model.Permission import ai.kilocode.client.session.model.PermissionMeta import ai.kilocode.client.session.model.SessionModel import ai.kilocode.client.session.model.SessionState -import ai.kilocode.client.session.ui.ProgressPanel import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/QuestionPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/QuestionPanelTest.kt index 9f16b995293..769b5dbbe08 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/QuestionPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/QuestionPanelTest.kt @@ -4,7 +4,7 @@ import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloSessionService import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.app.Workspace -import ai.kilocode.client.session.SessionController +import ai.kilocode.client.session.update.SessionController import ai.kilocode.client.session.model.Question import ai.kilocode.client.session.model.QuestionItem import ai.kilocode.client.session.model.QuestionOption diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt similarity index 96% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionPanelTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt index a0df7614d7a..c2f2a68858e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt @@ -11,24 +11,24 @@ import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase /** - * Tests for [SessionPanel] — structural and index integrity. + * Tests for [SessionMessageListPanel] — structural and index integrity. * * Uses [BasePlatformTestCase] for a real IntelliJ Application; layout * is not measured (no screen), but the structural / index state is fully * testable. */ @Suppress("UnstableApiUsage") -class SessionPanelTest : BasePlatformTestCase() { +class SessionMessageListPanelTest : BasePlatformTestCase() { private lateinit var model: SessionModel private lateinit var parent: Disposable - private lateinit var panel: SessionPanel + private lateinit var panel: SessionMessageListPanel override fun setUp() { super.setUp() parent = Disposer.newDisposable("test") model = SessionModel() - panel = SessionPanel(model, parent) + panel = SessionMessageListPanel(model, parent) } override fun tearDown() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionRootPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionRootPanelTest.kt new file mode 100644 index 00000000000..7d66c8604f1 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionRootPanelTest.kt @@ -0,0 +1,70 @@ +package ai.kilocode.client.session.ui + +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.awt.Dimension +import java.awt.Rectangle +import javax.swing.JLayeredPane +import javax.swing.JPanel + +@Suppress("UnstableApiUsage") +class SessionRootPanelTest : BasePlatformTestCase() { + + fun `test root owns content and overlay layers`() { + val root = SessionRootPanel() + + assertEquals(2, root.componentCount) + assertSame(root.content, root.components.first { it === root.content }) + assertSame(root.overlay, root.components.first { it === root.overlay }) + assertEquals(JLayeredPane.DEFAULT_LAYER, root.getLayer(root.content)) + assertEquals(JLayeredPane.PALETTE_LAYER, root.getLayer(root.overlay)) + } + + fun `test root layout fills immediate children`() { + val root = SessionRootPanel().apply { + setSize(320, 180) + } + + root.doLayout() + + assertEquals(Rectangle(0, 0, 320, 180), root.content.bounds) + assertEquals(Rectangle(0, 0, 320, 180), root.overlay.bounds) + } + + fun `test root preferred size is max of immediate children`() { + val root = SessionRootPanel().apply { + content.preferredSize = Dimension(300, 120) + overlay.preferredSize = Dimension(180, 220) + } + + assertEquals(Dimension(300, 220), root.preferredSize) + } + + fun `test addOverlay applies callback bounds and delegates child layout`() { + val root = SessionRootPanel().apply { + setSize(400, 260) + } + val child = Probe() + + root.addOverlay(child) { _, item -> + Rectangle(12, 34, item.preferredSize.width, item.preferredSize.height) + } + + root.doLayout() + + assertEquals(Rectangle(12, 34, 80, 24), child.bounds) + assertTrue(child.laid) + } + + private class Probe : JPanel() { + var laid = false + + init { + preferredSize = Dimension(80, 24) + } + + override fun doLayout() { + laid = true + super.doLayout() + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiUpdateTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt similarity index 96% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiUpdateTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt index 343271cdf7f..18f6888bf89 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiUpdateTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt @@ -1,8 +1,7 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.ui import ai.kilocode.client.session.model.SessionModel import ai.kilocode.client.session.model.SessionState -import ai.kilocode.client.session.ui.SessionPanel import ai.kilocode.client.session.views.TextView import ai.kilocode.rpc.dto.MessageDto import ai.kilocode.rpc.dto.MessageTimeDto @@ -14,7 +13,7 @@ import com.intellij.testFramework.fixtures.BasePlatformTestCase /** * Integration test: mutate [SessionModel] directly on the EDT and verify - * that [SessionPanel] reflects the changes without any end-to-end RPC flow. + * that [SessionMessageListPanel] reflects the changes without any end-to-end RPC flow. * * This tests the full model → event → view update pipeline in isolation. */ @@ -23,13 +22,13 @@ class SessionUiUpdateTest : BasePlatformTestCase() { private lateinit var model: SessionModel private lateinit var parent: Disposable - private lateinit var panel: SessionPanel + private lateinit var panel: SessionMessageListPanel override fun setUp() { super.setUp() parent = Disposer.newDisposable("test") model = SessionModel() - panel = SessionPanel(model, parent) + panel = SessionMessageListPanel(model, parent) } override fun tearDown() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/AppWatchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/AppWatchingTest.kt new file mode 100644 index 00000000000..efda159ca97 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/AppWatchingTest.kt @@ -0,0 +1,95 @@ +package ai.kilocode.client.session.update + +import ai.kilocode.rpc.dto.ConfigWarningDto +import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto + +class AppWatchingTest : SessionControllerTestBase() { + + fun `test app state change fires AppChanged`() { + val m = controller() + val events = collect(m) + flush() + events.clear() + + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + flush() + + assertControllerEvents("AppChanged", events) + assertSession( + """ + [app: READY] [workspace: PENDING] + """, + m, + show = false, + ) + } + + fun `test retry connection uses app retry when app is failed`() { + val m = controller() + val events = collect(m) + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.ERROR, error = "boom") + + flush() + events.clear() + edt { m.retryConnection() } + flush() + + assertEquals(1, appRpc.retries) + assertEquals(0, projectRpc.reloads) + assertTrue(events.any { it is SessionControllerEvent.ConnectionChanged.ShowConnecting }) + } + + fun `test retry connection reloads workspace when app ready and workspace failed`() { + val m = controller() + val events = collect(m) + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + projectRpc.state.value = ai.kilocode.rpc.dto.KiloWorkspaceStateDto( + status = KiloWorkspaceStatusDto.ERROR, + error = "workspace fail", + ) + + flush() + events.clear() + edt { m.retryConnection() } + flush() + + assertEquals(0, appRpc.retries) + assertEquals(1, projectRpc.reloads) + assertTrue(events.any { it is SessionControllerEvent.ConnectionChanged.ShowConnecting }) + } + + fun `test retry connection uses app retry when app has warnings`() { + val m = controller() + val events = collect(m) + appRpc.state.value = KiloAppStateDto( + status = KiloAppStatusDto.READY, + warnings = listOf(ConfigWarningDto(path = ".kilo/kilo.json", message = "Invalid JSON")), + ) + + flush() + events.clear() + edt { m.retryConnection() } + flush() + + assertEquals(1, appRpc.retries) + assertEquals(0, projectRpc.reloads) + assertTrue(events.any { it is SessionControllerEvent.ConnectionChanged.ShowConnecting }) + } + + fun `test retry connection immediately updates connection state`() { + val m = controller() + val states = collectStates(m) + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.ERROR, error = "boom") + flush() + states.clear() + + edt { m.retryConnection() } + flush() + + val state = states.single { it.first is SessionControllerEvent.ConnectionChanged.ShowConnecting }.second + assertEquals(SessionControllerEvent.ConnectionChanged.ShowConnecting, state.connectionState) + assertEquals(SessionControllerEvent.ConnectionChanged.ShowConnecting, state.connectionTargetState) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ChatLoggingFlowTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ChatLoggingFlowTest.kt similarity index 98% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ChatLoggingFlowTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ChatLoggingFlowTest.kt index 3a8e1f0c777..a2852df403f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ChatLoggingFlowTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ChatLoggingFlowTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.client.session.model.SessionState diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ConfigSelectionTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ConfigSelectionTest.kt similarity index 96% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ConfigSelectionTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ConfigSelectionTest.kt index eb53354c488..ae79e031dcd 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ConfigSelectionTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ConfigSelectionTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update class ConfigSelectionTest : SessionControllerTestBase() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ConnectionDelayTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ConnectionDelayTest.kt new file mode 100644 index 00000000000..d5269a7ca86 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ConnectionDelayTest.kt @@ -0,0 +1,230 @@ +package ai.kilocode.client.session.update + +import ai.kilocode.rpc.dto.ConfigWarningDto +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.KiloWorkspaceStateDto +import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import ai.kilocode.rpc.dto.LoadErrorDto +import com.intellij.openapi.util.Disposer + +class ConnectionDelayTest : SessionControllerTestBase() { + + fun `test short connecting state does not fire connection banner`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller(displayMs = 100) + val events = collect(m) + flush() + events.clear() + + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.CONNECTING) + pause(25) + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + pause(150) + + assertFalse(events.any { it is SessionControllerEvent.ConnectionChanged.ShowConnecting }) + } + + fun `test persistent connecting state fires connection banner after delay`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller(displayMs = 50) + val events = collect(m) + flush() + events.clear() + + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.CONNECTING) + pause(20) + assertFalse(events.any { it is SessionControllerEvent.ConnectionChanged.ShowConnecting }) + + pause(80) + + assertEquals(1, events.count { it is SessionControllerEvent.ConnectionChanged.ShowConnecting }) + } + + fun `test connecting event sees updated connection state on EDT`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller(displayMs = 50) + val states = collectStates(m) + flush() + states.clear() + + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.CONNECTING) + pause(80) + + val state = states.single { it.first is SessionControllerEvent.ConnectionChanged.ShowConnecting }.second + assertEquals(SessionControllerEvent.ConnectionChanged.ShowConnecting, state.connectionState) + } + + fun `test short app error is suppressed`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller(displayMs = 100) + val events = collect(m) + flush() + events.clear() + + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.ERROR, error = "boom") + pause(25) + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + pause(150) + + assertFalse(events.any { it is SessionControllerEvent.ConnectionChanged.ShowError }) + } + + fun `test persistent app error fires latest error after delay`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller(displayMs = 50) + val events = collect(m) + flush() + events.clear() + + appRpc.state.value = KiloAppStateDto( + status = KiloAppStatusDto.ERROR, + error = "CLI startup failed", + errors = listOf( + LoadErrorDto(resource = "connection", detail = "stderr line"), + LoadErrorDto(resource = "config", detail = "HTTP 500"), + ), + ) + pause(80) + + val event = events.filterIsInstance().single() + assertEquals("Connection failed", event.summary) + assertEquals("stderr line\nconfig: HTTP 500", event.detail) + } + + fun `test changed error restarts delay and shows latest state`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller(displayMs = 100) + val events = collect(m) + flush() + events.clear() + + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.ERROR, error = "first") + pause(50) + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.ERROR, error = "second") + pause(150) + + val errors = events.filterIsInstance() + assertEquals(listOf("Connection failed"), errors.map { it.summary }) + assertEquals(listOf("second"), errors.map { it.detail }) + } + + fun `test persistent workspace error is delayed`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller(displayMs = 50) + val events = collect(m) + flush() + events.clear() + + projectRpc.state.value = KiloWorkspaceStateDto( + status = KiloWorkspaceStatusDto.ERROR, + error = "workspace failed", + errors = listOf(LoadErrorDto(resource = "providers", detail = "bad provider json")), + ) + pause(20) + assertFalse(events.any { it is SessionControllerEvent.ConnectionChanged.ShowError }) + + pause(80) + + val event = events.filterIsInstance().single() + assertEquals("Workspace loading failed", event.summary) + assertEquals("providers: bad provider json", event.detail) + } + + fun `test ready hides visible delayed connection banner immediately`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller(displayMs = 50) + val events = collect(m) + flush() + events.clear() + + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.CONNECTING) + pause(80) + events.clear() + + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + pause(10) + + assertTrue(events.any { it is SessionControllerEvent.ConnectionChanged.Hide }) + } + + fun `test hide event sees updated connection state on EDT`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller(displayMs = 50) + val states = collectStates(m) + flush() + states.clear() + + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.CONNECTING) + pause(80) + states.clear() + + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + pause(10) + + val state = states.single { it.first is SessionControllerEvent.ConnectionChanged.Hide }.second + assertEquals(SessionControllerEvent.ConnectionChanged.Hide, state.connectionState) + } + + fun `test config warning remains immediate`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller(displayMs = 1_000) + val events = collect(m) + flush() + events.clear() + + appRpc.state.value = KiloAppStateDto( + status = KiloAppStatusDto.READY, + warnings = listOf(ConfigWarningDto(path = ".kilo/kilo.json", message = "Invalid JSON")), + ) + pause(10) + + val event = events.filterIsInstance().single() + assertEquals("Configuration warnings", event.summary) + assertEquals(".kilo/kilo.json: Invalid JSON", event.detail) + } + + fun `test warning event sees updated connection state on EDT`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller(displayMs = 1_000) + val states = collectStates(m) + flush() + states.clear() + + appRpc.state.value = KiloAppStateDto( + status = KiloAppStatusDto.READY, + warnings = listOf(ConfigWarningDto(path = ".kilo/kilo.json", message = "Invalid JSON")), + ) + pause(10) + + val event = states.single { it.first is SessionControllerEvent.ConnectionChanged.ShowWarning } + assertEquals(event.first, event.second.connectionState) + } + + fun `test dispose suppresses pending delayed connection event`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller(displayMs = 50) + val events = collect(m) + flush() + events.clear() + + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.CONNECTING) + pause(20) + Disposer.dispose(m) + pause(100) + + assertFalse(events.any { it is SessionControllerEvent.ConnectionChanged.ShowConnecting }) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/DelayedStateTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/DelayedStateTest.kt new file mode 100644 index 00000000000..8f6e5cfc3e0 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/DelayedStateTest.kt @@ -0,0 +1,148 @@ +package ai.kilocode.client.session.update + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.util.ui.UIUtil +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking + +class DelayedStateTest : BasePlatformTestCase() { + private val states = mutableListOf() + + override fun tearDown() { + try { + states.forEach { Disposer.dispose(it) } + states.clear() + } finally { + super.tearDown() + } + } + + fun `test run applies action after delay when state still matches`() { + var state = "loading" + val out = mutableListOf() + val delay = delayed(30) + + delay.run("loading", { state }) { out.add(it) } + pause(120) + + assertEquals(listOf("loading"), out) + } + + fun `test run does not apply when current state changed`() { + var state = "loading" + val out = mutableListOf() + val delay = delayed(30) + + delay.run("loading", { state }) { out.add(it) } + edt { state = "loaded" } + pause(120) + + assertTrue(out.isEmpty()) + } + + fun `test multiple pending matching actions can run from shared timer`() { + var first = "loading" + var second = "connecting" + val out = mutableListOf() + val delay = delayed(30) + + delay.run("loading", { first }) { out.add("first:$it") } + delay.run("connecting", { second }) { out.add("second:$it") } + pause(120) + + assertEquals(listOf("first:loading", "second:connecting"), out) + } + + fun `test stale and matching pending actions are evaluated independently`() { + var state = "first" + val out = mutableListOf() + val delay = delayed(30) + + delay.run("first", { state }) { out.add(it) } + edt { state = "second" } + delay.run("second", { state }) { out.add(it) } + pause(120) + + assertEquals(listOf("second"), out) + } + + fun `test cancel suppresses pending actions`() { + var state = "loading" + val out = mutableListOf() + val delay = delayed(30) + + delay.run("loading", { state }) { out.add(it) } + delay.cancel() + pause(120) + + assertTrue(out.isEmpty()) + } + + fun `test cancel stops timer`() { + var state = "loading" + val delay = delayed(30) + + delay.run("loading", { state }) {} + pause(10) + delay.cancel() + pause(10) + + assertFalse(delay.active()) + } + + fun `test timer stops after pending actions drain`() { + var state = "loading" + val delay = delayed(30) + + delay.run("loading", { state }) {} + pause(120) + + assertFalse(delay.active()) + } + + fun `test dispose suppresses pending actions`() { + var state = "loading" + val out = mutableListOf() + val delay = delayed(30) + + delay.run("loading", { state }) { out.add(it) } + Disposer.dispose(delay) + states.remove(delay) + pause(120) + + assertTrue(out.isEmpty()) + } + + fun `test zero delay applies on EDT`() { + var state = "loading" + val out = mutableListOf() + val delay = delayed(0) + + delay.run("loading", { state }) { + out.add(ApplicationManager.getApplication().isDispatchThread) + } + pause(30) + + assertEquals(listOf(true), out) + } + + private fun delayed(ms: Long): DelayedState { + val state = DelayedState(ms) + states.add(state) + return state + } + + private fun pause(ms: Long) = runBlocking { + val tick = 10L + repeat((ms / tick).coerceAtLeast(1).toInt()) { + delay(tick) + edt { UIUtil.dispatchAllInvocationEvents() } + } + } + + private fun edt(block: () -> Unit) { + ApplicationManager.getApplication().invokeAndWait(block) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/HistoryLoadingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/HistoryLoadingTest.kt similarity index 83% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/HistoryLoadingTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/HistoryLoadingTest.kt index 6a892a4a5f1..6f0cf34d24f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/HistoryLoadingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/HistoryLoadingTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.rpc.dto.MessageWithPartsDto @@ -29,8 +29,15 @@ class HistoryLoadingTest : SessionControllerTestBase() { rpc.history.add(MessageWithPartsDto(msg("msg1", "ses_test", "user"), emptyList())) val c = controller("ses_test") + val events = collect(c) flush() + assertControllerEvents(""" + AppChanged + WorkspaceChanged + ViewChanged session + """, events) + assertSession( """ user#msg1 diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ListenerLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ListenerLifecycleTest.kt similarity index 96% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ListenerLifecycleTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ListenerLifecycleTest.kt index a9a7de5c9df..e5d61e373fc 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ListenerLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ListenerLifecycleTest.kt @@ -1,6 +1,5 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update -import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.client.session.model.SessionState import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.SessionStatusDto @@ -25,7 +24,7 @@ class ListenerLifecycleTest : SessionControllerTestBase() { flush() assertControllerEvents(""" - ViewChanged show + ViewChanged session AppChanged WorkspaceChanged """, events) @@ -48,7 +47,7 @@ class ListenerLifecycleTest : SessionControllerTestBase() { assertEquals(events1, events2) assertControllerEvents(""" - ViewChanged show + ViewChanged session AppChanged WorkspaceChanged """, events1) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/MessageListTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/MessageListTest.kt similarity index 99% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/MessageListTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/MessageListTest.kt index a53b0655c16..b09da2064e1 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/MessageListTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/MessageListTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.rpc.dto.ChatEventDto diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ProgressTrackingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ProgressTrackingTest.kt similarity index 99% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ProgressTrackingTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ProgressTrackingTest.kt index f4db69ddd54..03fd591a5a1 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ProgressTrackingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ProgressTrackingTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.client.session.model.SessionState diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/PromptLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/PromptLifecycleTest.kt similarity index 99% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/PromptLifecycleTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/PromptLifecycleTest.kt index 38cb5abf77b..8e3cea3ba9f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/PromptLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/PromptLifecycleTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update import ai.kilocode.client.session.model.SessionState import ai.kilocode.rpc.dto.ChatEventDto diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionArtifactsTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionArtifactsTest.kt similarity index 99% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionArtifactsTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionArtifactsTest.kt index e25d544235a..d2afd1387e5 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionArtifactsTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionArtifactsTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.DiffFileDto diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionControllerTestBase.kt similarity index 84% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionControllerTestBase.kt index 8e79cec7dc9..a3db4287546 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionControllerTestBase.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloSessionService @@ -36,7 +36,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking /** - * Base class for [SessionController] tests. + * Base class for [ai.kilocode.client.session.update.SessionController] tests. * * Provides real IntelliJ Application/EDT/Disposer via [BasePlatformTestCase], * real frontend services wired to fake RPC backends, and shared helpers. @@ -120,15 +120,33 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { // ------ Controller creation ------ - protected fun controller(id: String? = null) = controller(id, Long.MAX_VALUE) - - protected fun controller(id: String? = null, flushMs: Long): SessionController { - return controller(id, flushMs, true) + protected fun controller( + id: String? = null, + flushMs: Long = Long.MAX_VALUE, + displayMs: Long = Long.MAX_VALUE, + ): SessionController { + return controller(id, flushMs, true, displayMs) } - protected fun controller(id: String? = null, flushMs: Long, condense: Boolean): SessionController { + protected fun controller( + id: String? = null, + flushMs: Long, + condense: Boolean, + displayMs: Long = Long.MAX_VALUE, + ): SessionController { val root = Root() - val m = SessionController(parent, id, sessions, workspace, app, scope, root, flushMs, condense) + val m = SessionController( + parent, + id, + sessions, + workspace, + app, + scope, + root, + flushMs, + condense, + displayMs + ) controllers.add(m) roots[m] = root return m @@ -156,12 +174,24 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { return events } + internal fun collectStates(m: SessionController): MutableList> { + val events = mutableListOf>() + val disposable = Disposer.newDisposable("state-listener") + Disposer.register(parent, disposable) + m.addListener(disposable) { event -> + assertTrue("Listener must be called on EDT", ApplicationManager.getApplication().isDispatchThread) + events.add(event to m.snapshotState()) + } + return events + } + /** Attach a listener that collects model events (messages, parts, phase). */ protected fun collectModelEvents(m: SessionController): MutableList { val events = mutableListOf() val disposable = Disposer.newDisposable("model-listener") Disposer.register(parent, disposable) m.model.addListener(disposable) { event -> + assertTrue("Model listener must be called on EDT", ApplicationManager.getApplication().isDispatchThread) events.add(event) } return events @@ -181,7 +211,17 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { protected fun flush() = runBlocking { repeat(5) { delay(100) - controllers.forEach { it.flushEvents() } + edt { + controllers.forEach { it.flushEvents() } + UIUtil.dispatchAllInvocationEvents() + } + } + } + + protected fun pause(ms: Long) = runBlocking { + val tick = 10L + repeat((ms / tick).coerceAtLeast(1).toInt()) { + delay(tick) edt { UIUtil.dispatchAllInvocationEvents() } } } @@ -219,7 +259,7 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { protected fun assertSession(expected: String, c: SessionController, show: Boolean = true) { assertEquals(expected.trimIndent().trim(), c.toString().trim()) - assertEquals(show, c.model.showMessages) + assertEquals(show, c.model.showSession) } protected fun assertControllerEvents(expected: String, events: List) { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionCreationTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionCreationTest.kt similarity index 91% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionCreationTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionCreationTest.kt index ac7d2c9a944..36fe6220905 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionCreationTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionCreationTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update class SessionCreationTest : SessionControllerTestBase() { @@ -14,7 +14,7 @@ class SessionCreationTest : SessionControllerTestBase() { assertEquals(1, rpc.creates) assertEquals(1, rpc.prompts.size) assertEquals("ses_test", rpc.prompts[0].first) - assertControllerEvents("ViewChanged show", events) + assertControllerEvents("ViewChanged session", events) assertSession( """ [app: DISCONNECTED] [workspace: PENDING] diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionQueueCondenserTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionQueueCondenserTest.kt similarity index 99% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionQueueCondenserTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionQueueCondenserTest.kt index 0e5cf03e171..c7871d4332e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionQueueCondenserTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionQueueCondenserTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.DiffFileDto diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionRecoveryTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionRecoveryTest.kt similarity index 98% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionRecoveryTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionRecoveryTest.kt index 87d68d4ff32..e407fb61b49 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionRecoveryTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionRecoveryTest.kt @@ -1,17 +1,16 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update import ai.kilocode.client.session.model.SessionState import ai.kilocode.rpc.dto.PermissionRequestDto import ai.kilocode.rpc.dto.QuestionInfoDto import ai.kilocode.rpc.dto.QuestionRequestDto import ai.kilocode.rpc.dto.SessionStatusDto -import ai.kilocode.rpc.dto.SessionTimeDto /** * Tests for pending permission/question recovery after history load. * * VS Code rehydrates pending prompts by calling list endpoints after - * reconnect. JetBrains now does the same in [SessionController.recoverPending]. + * reconnect. JetBrains now does the same in [ai.kilocode.client.session.update.SessionController.recoverPending]. */ class SessionRecoveryTest : SessionControllerTestBase() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUpdateQueueTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionUpdateQueueTest.kt similarity index 99% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUpdateQueueTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionUpdateQueueTest.kt index 04dc2ad4f3e..a204f24dc39 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUpdateQueueTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionUpdateQueueTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/StatusComputationTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/StatusComputationTest.kt similarity index 97% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/StatusComputationTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/StatusComputationTest.kt index 98719ec3c36..27adf704416 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/StatusComputationTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/StatusComputationTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.rpc.dto.ChatEventDto diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/TurnLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/TurnLifecycleTest.kt similarity index 99% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/TurnLifecycleTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/TurnLifecycleTest.kt index 317271b1a60..80aa8367511 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/TurnLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/TurnLifecycleTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update import ai.kilocode.client.session.model.SessionState import ai.kilocode.rpc.dto.ChatEventDto diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ViewSwitchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ViewSwitchingTest.kt new file mode 100644 index 00000000000..1f5de63b397 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ViewSwitchingTest.kt @@ -0,0 +1,212 @@ +package ai.kilocode.client.session.update + +import kotlinx.coroutines.CompletableDeferred + +class ViewSwitchingTest : SessionControllerTestBase() { + + fun `test first prompt shows messages view`() { + val m = controller() + val events = collect(m) + flush() + events.clear() + + edt { m.prompt("hello") } + flush() + + assertControllerEvents("ViewChanged session", events) + assertSession( + """ + [app: DISCONNECTED] [workspace: PENDING] + """, + m, + ) + } + + fun `test session event sees updated view state on EDT`() { + val m = controller() + val states = collectStates(m) + flush() + states.clear() + + edt { m.prompt("hello") } + flush() + + val state = states.single { it.first is SessionControllerEvent.ViewChanged.ShowSession }.second + assertTrue(state.showSession) + assertEquals(SessionControllerEvent.ViewChanged.ShowSession, state.viewState) + } + + fun `test ViewChanged not fired twice`() { + val m = controller() + val events = collect(m) + flush() + events.clear() + + edt { m.prompt("first") } + flush() + edt { m.prompt("second") } + flush() + + assertControllerEvents("ViewChanged session", events) + } + + fun `test recent sessions show after workspace ready`() { + projectRpc.state.value = workspaceReady() + rpc.recent.add(session("ses_1")) + val m = controller() + val events = collect(m) + + flush() + + assertTrue(rpc.recentCalls.contains("/test" to SessionController.RECENT_LIMIT)) + assertControllerEvents(""" + AppChanged + WorkspaceChanged + WorkspaceReady + ViewChanged recents=1 + """, events) + } + + fun `test recent load failure shows empty recents`() { + projectRpc.state.value = workspaceReady() + rpc.recentFailures = 1 + val m = controller() + val events = collect(m) + + flush() + + assertTrue(rpc.recentCalls.contains("/test" to SessionController.RECENT_LIMIT)) + assertControllerEvents(""" + AppChanged + WorkspaceChanged + WorkspaceReady + ViewChanged recents=0 + """, events) + } + + fun `test empty history transitions to recents`() { + rpc.recent.add(session("ses_1")) + val m = controller("ses_test", displayMs = 1_000) + val events = collect(m) + + flush() + + assertControllerEvents(""" + AppChanged + WorkspaceChanged + ViewChanged recents=1 + """, events) + } + + fun `test slow recents show progress after delay then recents`() { + projectRpc.state.value = workspaceReady() + rpc.recent.add(session("ses_1")) + val gate = CompletableDeferred() + rpc.recentGate = gate + val m = controller(displayMs = 50) + val events = collect(m) + + pause(20) + assertTrue(rpc.recentCalls.contains("/test" to SessionController.RECENT_LIMIT)) + assertFalse(events.any { it is SessionControllerEvent.ViewChanged.ShowProgress }) + + pause(80) + assertTrue(events.any { it is SessionControllerEvent.ViewChanged.ShowProgress }) + + gate.complete(Unit) + flush() + + assertEquals( + """ + ViewChanged progress + ViewChanged recents=1 + """.trimIndent().trim(), + events.filterIsInstance().joinToString("\n"), + ) + } + + fun `test recents progress event sees loading state on EDT`() { + projectRpc.state.value = workspaceReady() + rpc.recent.add(session("ses_1")) + val gate = CompletableDeferred() + rpc.recentGate = gate + val m = controller(displayMs = 50) + val states = collectStates(m) + + pause(80) + + val state = states.single { it.first is SessionControllerEvent.ViewChanged.ShowProgress }.second + assertEquals(SessionControllerEvent.ViewChanged.ShowProgress, state.viewState) + assertTrue(state.recentsState.startsWith("Loading")) + + gate.complete(Unit) + flush() + } + + fun `test fast recents suppress progress`() { + projectRpc.state.value = workspaceReady() + rpc.recent.add(session("ses_1")) + val m = controller(displayMs = 1_000) + val events = collect(m) + + flush() + + assertTrue(rpc.recentCalls.contains("/test" to SessionController.RECENT_LIMIT)) + assertFalse(events.any { it is SessionControllerEvent.ViewChanged.ShowProgress }) + assertTrue(events.any { it is SessionControllerEvent.ViewChanged.ShowRecents }) + } + + fun `test recents event sees loaded state on EDT`() { + projectRpc.state.value = workspaceReady() + rpc.recent.add(session("ses_1")) + val m = controller(displayMs = 1_000) + val states = collectStates(m) + + flush() + + val event = states.single { it.first is SessionControllerEvent.ViewChanged.ShowRecents } + assertEquals(event.first, event.second.viewState) + assertEquals("Loaded", event.second.recentsState) + } + + fun `test failed fast recents suppress progress and show empty recents`() { + projectRpc.state.value = workspaceReady() + rpc.recentFailures = 1 + val m = controller(displayMs = 1_000) + val events = collect(m) + + flush() + + assertFalse(events.any { it is SessionControllerEvent.ViewChanged.ShowProgress }) + assertEquals(1, events.count { it is SessionControllerEvent.ViewChanged.ShowRecents }) + assertTrue(events.filterIsInstance().single().recents.isEmpty()) + } + + fun `test recents progress is canceled when messages view appears`() { + projectRpc.state.value = workspaceReady() + rpc.recent.add(session("ses_1")) + val gate = CompletableDeferred() + rpc.recentGate = gate + val m = controller(displayMs = 50) + val events = collect(m) + + pause(20) + edt { m.prompt("hello") } + pause(80) + gate.complete(Unit) + flush() + + assertTrue(events.any { it is SessionControllerEvent.ViewChanged.ShowSession }) + assertFalse(events.any { it is SessionControllerEvent.ViewChanged.ShowProgress }) + assertFalse(events.any { it is SessionControllerEvent.ViewChanged.ShowRecents }) + } + + private fun session(id: String) = ai.kilocode.rpc.dto.SessionDto( + id = id, + projectID = "prj", + directory = "/test", + title = "Title $id", + version = "1", + time = ai.kilocode.rpc.dto.SessionTimeDto(created = 1.0, updated = 2.0), + ) +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/WorkspaceWatchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/WorkspaceWatchingTest.kt similarity index 93% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/WorkspaceWatchingTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/WorkspaceWatchingTest.kt index 92ba9f79785..cf711158cd1 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/WorkspaceWatchingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/WorkspaceWatchingTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session +package ai.kilocode.client.session.update class WorkspaceWatchingTest : SessionControllerTestBase() { @@ -17,6 +17,7 @@ class WorkspaceWatchingTest : SessionControllerTestBase() { assertEquals("gpt-5", m.model.models[0].id) assertFalse(m.model.isReady()) assertControllerEvents(""" + ViewChanged recents=0 WorkspaceChanged WorkspaceReady """, events) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt index 0919fcf3cbd..ea5b697745c 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt @@ -21,6 +21,8 @@ class FakeAppRpcApi : KiloAppRpcApi { var connected = false private set + var retries = 0 + private set override suspend fun connect() { assertNotEdt("connect") @@ -37,6 +39,11 @@ class FakeAppRpcApi : KiloAppRpcApi { return health } + override suspend fun retry() { + assertNotEdt("retry") + retries += 1 + } + override suspend fun restart() { assertNotEdt("restart") } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt index de70b1424c9..8759f4d913e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt @@ -14,6 +14,7 @@ import ai.kilocode.rpc.dto.SessionDto import ai.kilocode.rpc.dto.SessionListDto import ai.kilocode.rpc.dto.SessionStatusDto import ai.kilocode.rpc.dto.SessionTimeDto +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -42,6 +43,11 @@ class FakeSessionRpcApi : KiloSessionRpcApi { /** Message history returned by [messages]. */ val history = mutableListOf() + /** Recent sessions returned by [recent]. */ + val recent = mutableListOf() + var recentFailures = 0 + var recentGate: CompletableDeferred? = null + /** Push chat events here; tests collect from [events]. */ val events = MutableSharedFlow(extraBufferCapacity = 64, replay = 64) @@ -66,6 +72,7 @@ class FakeSessionRpcApi : KiloSessionRpcApi { val permissionRulesSaved = mutableListOf>() val questionReplies = mutableListOf>() val questionRejects = mutableListOf>() + val recentCalls = mutableListOf>() var creates = 0 private set @@ -82,6 +89,17 @@ class FakeSessionRpcApi : KiloSessionRpcApi { return SessionListDto(emptyList(), emptyMap()) } + override suspend fun recent(directory: String, limit: Int): SessionListDto { + assertNotEdt("recent") + recentCalls.add(directory to limit) + recentGate?.await() + if (recentFailures > 0) { + recentFailures-- + throw IllegalStateException("recent unavailable") + } + return SessionListDto(recent.take(limit), emptyMap()) + } + override suspend fun get(id: String, directory: String): SessionDto { assertNotEdt("get") return session diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt index a85a1078058..c218008a5b1 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt @@ -18,6 +18,8 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi { var directory = "/test" val state = MutableStateFlow(KiloWorkspaceStateDto(KiloWorkspaceStatusDto.PENDING)) + var reloads = 0 + private set override suspend fun resolveProjectDirectory(hint: String): String { assertNotEdt("resolveProjectDirectory") @@ -31,5 +33,6 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi { override suspend fun reload(directory: String) { assertNotEdt("reload") + reloads += 1 } } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt index e0ea02ea1c2..01c614f7e09 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt @@ -31,6 +31,9 @@ interface KiloAppRpcApi : RemoteApi { /** One-shot health check against /global/health. */ suspend fun health(): HealthDto + /** Retry app connection or loading after a failure. */ + suspend fun retry() + /** Kill the CLI process and restart it. */ suspend fun restart() diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt index bffa2bf5be7..330bfb8c113 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt @@ -37,6 +37,9 @@ interface KiloSessionRpcApi : RemoteApi { /** List root sessions for a directory. */ suspend fun list(directory: String): SessionListDto + /** List recent root sessions for the current worktree family. */ + suspend fun recent(directory: String, limit: Int): SessionListDto + /** Create a new session in the given directory. */ suspend fun create(directory: String): SessionDto diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloAppStateDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloAppStateDto.kt index 0311799c2a5..3035f93d5f0 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloAppStateDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloAppStateDto.kt @@ -32,10 +32,18 @@ data class LoadErrorDto( val detail: String? = null, ) +@Serializable +data class ConfigWarningDto( + val path: String, + val message: String, + val detail: String? = null, +) + @Serializable data class KiloAppStateDto( val status: KiloAppStatusDto, val error: String? = null, val errors: List = emptyList(), val progress: LoadProgressDto? = null, + val warnings: List = emptyList(), ) diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloWorkspaceStateDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloWorkspaceStateDto.kt index c1dd8585db5..8b162af5d14 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloWorkspaceStateDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloWorkspaceStateDto.kt @@ -27,4 +27,5 @@ data class KiloWorkspaceStateDto( val commands: List = emptyList(), val skills: List = emptyList(), val error: String? = null, + val errors: List = emptyList(), )