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 7c94a86d76f0..00c62880f127 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 @@ -39,6 +39,7 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine @@ -128,6 +129,17 @@ class KiloBackendAppService private constructor( private val _appState = MutableStateFlow(KiloAppState.Disconnected) val appState: StateFlow = _appState.asStateFlow() + /** + * Whether the connected CLI allows background subagents, from `GET /experimental/capabilities`. + * + * Deliberately not part of [AppData]: it is a property of the connected CLI rather than loaded + * app data, it is probed off the load's critical path, and folding it into [AppData] would churn + * that object's identity — which `updateConfig` and [refreshConfigState] use to detect a + * concurrent reload, so a late probe would silently cancel a config write. + */ + private val _capabilities = MutableStateFlow(false) + val capabilities: StateFlow = _capabilities.asStateFlow() + val events: SharedFlow get() = connection.events val api: DefaultApi? get() = connection.api val http: OkHttpClient? get() = connection.apiClient @@ -340,7 +352,10 @@ class KiloBackendAppService private constructor( return@collect } when (next) { - ConnectionState.Disconnected -> _appState.value = KiloAppState.Disconnected + ConnectionState.Disconnected -> { + _capabilities.value = false + _appState.value = KiloAppState.Disconnected + } is ConnectionState.Downloading -> _appState.value = KiloAppState.Downloading(next.percent, next.version, next.platform) ConnectionState.Connecting -> _appState.value = KiloAppState.Connecting is ConnectionState.Connected -> { @@ -467,6 +482,12 @@ class KiloBackendAppService private constructor( "notifications=${notifs.size} ${configSummary(cfg)}", ) log.info("Application started — config, profile, notifications loaded") + // Off the critical path on purpose: this is an optional probe, and the generated + // client's call is blocking, so a timeout inside the load's coroutineScope could + // not actually release it — structured concurrency would still wait for the + // socket and fail an otherwise-successful load. Ready therefore starts with the + // capability off and flips once the probe answers. + cs.launch { refreshCapabilities() } } catch (e: TimeoutCancellationException) { val err = LoadError( resource = "app", @@ -659,6 +680,37 @@ class KiloBackendAppService private constructor( } } + /** + * Reads the CLI's background-subagent capability. Returns false when it cannot be read — an older + * CLI has no `/experimental/capabilities` route at all — which matches VS Code's + * `data?.backgroundSubagents === true` fallback and hides the affordance rather than offering one + * that would fail. + * + * Called off the load's critical path — see the call site in the start flow. + */ + private suspend fun fetchCapabilities(): Boolean { + val client = connection.appLoadApi ?: return false + return try { + withContext(Dispatchers.IO) { client.experimentalCapabilitiesGet().backgroundSubagents } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + log.warn("Experimental capabilities fetch failed: ${e.message}", e) + false + } + } + + /** + * Publishes the probe result into [capabilities], ignoring a late answer that arrives after the + * connection changed under us. + */ + private suspend fun refreshCapabilities() { + val conn = connection.state.value as? ConnectionState.Connected ?: return + val enabled = fetchCapabilities() + if (connection.state.value != conn) return + _capabilities.value = enabled + } + private suspend fun refreshConfigState() { val current = _appState.value as? KiloAppState.Ready ?: return val connection = connection.state.value as? ConnectionState.Connected ?: return @@ -836,6 +888,7 @@ class KiloBackendAppService private constructor( profile = null config = null notifications = emptyList() + _capabilities.value = false _appState.value = KiloAppState.Disconnected } 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 1e9d693cba5c..f29bdfba045f 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 @@ -6,6 +6,7 @@ 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.BackgroundJobDto import ai.kilocode.rpc.dto.CloudSessionListDto import ai.kilocode.rpc.dto.SessionBoardDto import ai.kilocode.rpc.dto.SessionChangeDto @@ -19,14 +20,23 @@ import ai.kilocode.rpc.dto.SessionSummaryDto import ai.kilocode.rpc.dto.SessionTimeDto import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.Dispatchers import kotlinx.serialization.json.JsonPrimitive import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.MediaType.Companion.toMediaType @@ -53,6 +63,11 @@ class KiloBackendSessionManager( private val cs: CoroutineScope, private val log: KiloLog, ) { + companion object { + private const val FAST_POLL_MS = 1_000L + private const val SLOW_POLL_MS = 5_000L + } + /** Per-session directory overrides (sessionId → worktree path). */ private val directories = ConcurrentHashMap() @@ -71,6 +86,9 @@ class KiloBackendSessionManager( private var base: String? = null private var watcher: Job? = null + /** One shared, poll-backed flow per (directory, parent session) key — see [backgroundJobs]. */ + private val jobFlows = ConcurrentHashMap>>() + fun start(api: DefaultApi, httpClient: OkHttpClient, port: Int, events: SharedFlow) { client = api http = httpClient @@ -110,6 +128,7 @@ class KiloBackendSessionManager( http = null base = null owned.clear() + jobFlows.clear() _statuses.value = emptyMap() log.info("Session manager stopped") } @@ -326,6 +345,112 @@ class KiloBackendSessionManager( } } + // ------ background subagents ------ + + /** + * Observe background subagent jobs owned by root session [id] in [dir]. + * + * Backed by a single poller per (directory, id) pair, shared across every subscriber via + * [kotlinx.coroutines.flow.shareIn] with [SharingStarted.WhileSubscribed] — the underlying + * `flow{}` coroutine starts on first collector and stops automatically once the last one leaves, + * so an open session's own subscription and a re-opened editor tab reuse the same poll loop + * instead of hitting the CLI twice. [http]/[base] are read fresh every iteration, so the poller + * pauses while disconnected (both go null in [stop]) and resumes on the next [start] without + * needing to be recreated. Cadence adapts: 1 s while any job is running, 5 s once the list is + * empty or every job is terminal, matching the VS Code webview's poll rate for the fast case. + */ + fun backgroundJobs(id: String, dir: String): Flow> { + val key = jobKey(dir, id) + return jobFlows.getOrPut(key) { + pollBackgroundJobs(id, dir) + // [SharingStarted.WhileSubscribed] stops the poll loop when the last collector + // leaves, which cancels this upstream and runs onCompletion — the point to drop the + // cache entry. Without it the SharedFlow and its replayed jobs list would be retained + // for every session opened during a long IDE run. A collector arriving in the same + // instant keeps working (sharing simply restarts); the next caller shares a fresh + // flow, so the race costs at most one extra poller, never correctness. + .onCompletion { jobFlows.remove(key) } + .shareIn(cs, SharingStarted.WhileSubscribed(), replay = 1) + } + } + + private fun jobKey(dir: String, id: String) = "$dir\u0000$id" + + private fun pollBackgroundJobs(id: String, dir: String): Flow> = flow { + var cadence = FAST_POLL_MS + while (true) { + val h = http + val url = base + if (h != null && url != null) { + val jobs = withContext(Dispatchers.IO) { + runCatching { fetchBackgroundJobs(h, url, id, dir) } + .onFailure { log.warn("${ChatLogSummary.sid(id)} kind=background-jobs poll=true failed message=${it.message}", it) } + .getOrNull() + } + if (jobs != null) { + emit(jobs) + cadence = if (jobs.any { it.status == "running" }) FAST_POLL_MS else SLOW_POLL_MS + } + } + delay(cadence) + } + }.distinctUntilChanged() + + private fun fetchBackgroundJobs(h: OkHttpClient, url: String, id: String, dir: String): List { + val target = url.toHttpUrl().newBuilder() + .addPathSegment("kilocode") + .addPathSegment("background-jobs") + .addQueryParameter("directory", dir) + .addQueryParameter("sessionID", id) + .build() + val request = Request.Builder().url(target).get().build() + h.newCall(request).execute().use { response -> + val raw = response.body?.string() + if (!response.isSuccessful) { + throw RuntimeException("Background jobs list failed: HTTP ${response.code} — $raw") + } + return KiloCliDataParser.parseBackgroundJobs(raw!!) + } + } + + /** + * Cancel background job [id] via `POST /kilocode/background-jobs/{id}/cancel?directory={dir}`. + * Cancels the job's child session tree, not just the job entry. Raw HTTP — these routes are + * newer than the generated client built from the pinned CLI release, matching [sessionBoard]. + */ + fun cancelBackgroundJob(id: String, dir: String): Boolean = + postBackgroundJobAction(id, dir, "cancel") + + /** + * Continue background job [id] in the background via + * `POST /kilocode/background-jobs/{id}/promote?directory={dir}`. Returns `false` when the CLI's + * `KILO_EXPERIMENTAL_BACKGROUND_SUBAGENTS` kill switch is off — callers must not assume success. + */ + fun promoteBackgroundJob(id: String, dir: String): Boolean = + postBackgroundJobAction(id, dir, "promote") + + private fun postBackgroundJobAction(id: String, dir: String, action: String): Boolean { + val h = http ?: throw IllegalStateException("Session manager not started") + val url = base ?: throw IllegalStateException("Session manager not started") + val target = url.toHttpUrl().newBuilder() + .addPathSegment("kilocode") + .addPathSegment("background-jobs") + .addPathSegment(id) + .addPathSegment(action) + .addQueryParameter("directory", dir) + .build() + log.info("Background job $action: POST $target") + val request = Request.Builder().url(target).post(ByteArray(0).toRequestBody(null)).build() + h.newCall(request).execute().use { response -> + val raw = response.body?.string() + if (!response.isSuccessful) { + log.warn("Background job $action failed: HTTP ${response.code}, body=$raw") + throw RuntimeException("Background job $action failed: HTTP ${response.code} — $raw") + } + return raw?.trim() == "true" + } + } + fun get(id: String, dir: String): SessionDto { val all = requireClient().sessionList(directory = dir) val raw = all.firstOrNull { it.id == id } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt index 56157ebc5af5..3bbd4bcee38d 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt @@ -13,6 +13,7 @@ import ai.kilocode.backend.workspace.ModelTerminalBenchInfo import ai.kilocode.backend.workspace.ProviderData import ai.kilocode.backend.workspace.ProviderInfo import ai.kilocode.rpc.dto.AgentConfigDto +import ai.kilocode.rpc.dto.BackgroundJobDto import ai.kilocode.rpc.dto.BoardMessageDto import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.CloudSessionDto @@ -450,6 +451,40 @@ object KiloCliDataParser { ) } + /** + * Parse a background-jobs list response (`GET /kilocode/background-jobs`) into + * [BackgroundJobDto]s, flattening the open `metadata` map's `sessionId`/`parentSessionId`/ + * `background` keys into typed fields. A row missing `id`/`type`/`status` is dropped rather + * than failing the whole list. + * + * `started_at`/`completed_at` normally arrive as numbers, but the generated SDK types widen them + * to `"NaN"`/`"Infinity"`/`"-Infinity"` strings — [JsonObject.num] parses both via + * [String.toDoubleOrNull], and a non-finite result falls back to the given default rather than a + * garbage [Long]. + */ + fun parseBackgroundJobs(raw: String): List { + val arr = tryParseArray(raw) ?: return emptyList() + return arr.mapNotNull { elem -> + val row = elem.obj() ?: return@mapNotNull null + val id = row.str("id") ?: return@mapNotNull null + val type = row.str("type") ?: return@mapNotNull null + val status = row.str("status") ?: return@mapNotNull null + val metadata = row["metadata"].obj() + BackgroundJobDto( + id = id, + type = type, + status = status, + title = row.str("title"), + startedAt = row.num("started_at")?.takeIf { it.isFinite() }?.toLong() ?: 0L, + completedAt = row.num("completed_at")?.takeIf { it.isFinite() }?.toLong(), + error = row.str("error"), + sessionId = metadata?.str("sessionId"), + parentSessionId = metadata?.str("parentSessionId"), + background = metadata.bool("background"), + ) + } + } + /** * Parse message history response (`GET /session/{id}/message`) * into a list of messages with their parts. 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 3548fb84cc07..680b235f0ca4 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 @@ -43,6 +43,7 @@ import com.intellij.openapi.project.RootsChangeRescanningInfo import com.intellij.openapi.roots.ex.ProjectRootManagerEx import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext @@ -61,7 +62,8 @@ class KiloAppRpcApiImpl : KiloAppRpcApi { override suspend fun connect() = app.connect() override suspend fun state(): Flow = - app.appState.map(::dto).distinctUntilChanged() + combine(app.appState, app.capabilities) { state, caps -> appStateDto(state, caps) } + .distinctUntilChanged() override suspend fun health(): HealthDto = app.health() @@ -104,7 +106,7 @@ class KiloAppRpcApiImpl : KiloAppRpcApi { override suspend fun updateConfig(patch: ConfigPatchDto): KiloAppStateDto { app.requireReady() - return appStateDto(app.updateConfig(patch)) + return appStateDto(app.updateConfig(patch), app.capabilities.value) } override suspend fun applyLogConfig(config: LogConfigDto) { @@ -147,11 +149,9 @@ class KiloAppRpcApiImpl : KiloAppRpcApi { service().capture(app.http, app.port, capture.event, capture.properties) } - private fun dto(state: KiloAppState): KiloAppStateDto = - appStateDto(state) } -internal fun appStateDto(state: KiloAppState): KiloAppStateDto = +internal fun appStateDto(state: KiloAppState, backgroundSubagents: Boolean = false): KiloAppStateDto = when (state) { KiloAppState.Disconnected -> KiloAppStateDto(KiloAppStatusDto.DISCONNECTED) is KiloAppState.Downloading -> KiloAppStateDto( @@ -179,6 +179,7 @@ internal fun appStateDto(state: KiloAppState): KiloAppStateDto = ), config = state.data.config, profile = state.data.profile?.let(::profileDto), + backgroundSubagents = backgroundSubagents, ) is KiloAppState.Error -> KiloAppStateDto( status = KiloAppStatusDto.ERROR, 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 238b0600c931..6032c1a78b82 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 @@ -10,6 +10,7 @@ import ai.kilocode.backend.app.KiloBackendSessionManager import ai.kilocode.backend.workspace.KiloBackendWorkspaceManager import ai.kilocode.log.ChatLogSummary import ai.kilocode.rpc.KiloSessionRpcApi +import ai.kilocode.rpc.dto.BackgroundJobDto import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.CloudSessionListDto import ai.kilocode.rpc.dto.DiffFileDto @@ -335,6 +336,17 @@ class KiloSessionRpcApiImpl internal constructor( override suspend fun resetSessionBoard(sessionID: String, directory: String, revision: Int): SessionBoardDto? = ready { withContext(Dispatchers.IO) { sessions.resetSessionBoard(sessionID, directory, revision) } } + // ------ background subagents ------ + + override suspend fun backgroundJobs(id: String, directory: String): Flow> = + sessions.backgroundJobs(id, directory) + + override suspend fun cancelBackgroundJob(id: String, directory: String): Boolean = + ready { withContext(Dispatchers.IO) { sessions.cancelBackgroundJob(id, directory) } } + + override suspend fun promoteBackgroundJob(id: String, directory: String): Boolean = + ready { withContext(Dispatchers.IO) { sessions.promoteBackgroundJob(id, directory) } } + private suspend fun ready(block: suspend () -> T): T { app.requireReady() return block() 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 40c1dd512d25..fe47f5920cab 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 @@ -112,6 +112,68 @@ class KiloBackendAppServiceTest { assertNotNull(ready.data.notifications) } + @Test + fun `ready reports the background subagent capability`() = runBlocking { + val svc = create() + svc.connect() + + ready(svc) + + // The probe runs off the load's critical path, so Ready starts with it off and flips once + // the answer lands. + withTimeout(10_000) { svc.capabilities.first { it } } + assertNotNull(mock.lastCapabilitiesPath) + } + + @Test + fun `background subagent capability is false when the CLI reports it off`() = runBlocking { + mock.capabilities = """{"backgroundSubagents":false}""" + val svc = create() + svc.connect() + + ready(svc) + awaitCapabilityProbe() + + assertFalse(svc.capabilities.value) + } + + @Test + fun `a hung capability probe still reaches Ready`() = runBlocking { + // The probe's client call is blocking, so it cannot live inside the load's coroutineScope: + // structured concurrency would wait on the socket and time the whole load out. + val gate = java.util.concurrent.CountDownLatch(1) + mock.capabilitiesGate = gate + val svc = create() + try { + svc.connect() + + ready(svc) + + assertFalse(svc.capabilities.value) + } finally { + gate.countDown() + } + } + + @Test + fun `unreadable capability leaves it off without failing the load`() = runBlocking { + // An older CLI has no /experimental/capabilities route at all; that must not block Ready. + mock.capabilitiesStatus = 404 + val svc = create() + svc.connect() + + ready(svc) + awaitCapabilityProbe() + + assertFalse(svc.capabilities.value) + } + + private suspend fun awaitCapabilityProbe() { + withTimeout(10_000) { + while (mock.lastCapabilitiesPath == null) delay(20) + } + } + @Test fun `download progress maps to app state before ready`() = runBlocking { val resolved = CompletableDeferred() diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendSessionManagerBackgroundJobsTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendSessionManagerBackgroundJobsTest.kt new file mode 100644 index 000000000000..3cbc32e84d07 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendSessionManagerBackgroundJobsTest.kt @@ -0,0 +1,158 @@ +package ai.kilocode.backend.app + +import ai.kilocode.backend.testing.FakeCliServer +import ai.kilocode.backend.testing.MockCliServer +import ai.kilocode.backend.testing.TestLog +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import java.net.URLDecoder +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class KiloBackendSessionManagerBackgroundJobsTest { + + private val mock = MockCliServer() + private val log = TestLog() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val apps = mutableListOf() + + @AfterTest + fun tearDown() { + apps.forEach { it.dispose() } + apps.clear() + scope.cancel() + mock.close() + } + + private fun setup(): KiloBackendAppService = + KiloBackendAppService.create(scope, FakeCliServer(mock), log).also { apps.add(it) } + + private suspend fun ready(app: KiloBackendAppService) { + app.connect() + withTimeout(10_000) { + app.appState.first { it is KiloAppState.Ready } + } + } + + @Test + fun `backgroundJobs parses the list and flattens metadata`() = runBlocking { + mock.backgroundJobs = """ + [ + { + "id": "job1", + "type": "task", + "status": "running", + "title": "Explore", + "started_at": 1000, + "metadata": {"sessionId": "ses_child1", "parentSessionId": "ses_root", "background": true} + } + ] + """.trimIndent() + val app = setup() + ready(app) + + val jobs = withTimeout(10_000) { app.sessions.backgroundJobs("ses_root", "/repo").first() } + + val job = jobs.single() + assertEquals("job1", job.id) + assertEquals("ses_child1", job.sessionId) + assertEquals("ses_root", job.parentSessionId) + assertTrue(job.background) + val path = mock.lastBackgroundJobsPath ?: error("missing background-jobs request") + assertTrue(path.startsWith("/kilocode/background-jobs?"), "Expected background-jobs path, got $path") + val decoded = URLDecoder.decode(path, "UTF-8") + assertTrue(decoded.contains("directory=/repo"), decoded) + assertTrue(decoded.contains("sessionID=ses_root"), decoded) + } + + @Test + fun `backgroundJobs shares one poller across concurrent subscribers`() = runBlocking { + val app = setup() + ready(app) + + val a = async { app.sessions.backgroundJobs("ses_root", "/repo").first() } + val b = async { app.sessions.backgroundJobs("ses_root", "/repo").first() } + withTimeout(10_000) { awaitAll(a, b) } + + assertEquals(1, mock.backgroundJobsRequests.size) + } + + @Test + fun `backgroundJobs still serves and shares after the last collector left`() = runBlocking { + val app = setup() + ready(app) + + // Guards the cache-eviction path: the entry is dropped when sharing stops, so a later + // subscriber must still get a working flow, and concurrent subscribers must still share one + // poller rather than each starting their own. + withTimeout(10_000) { app.sessions.backgroundJobs("ses_root", "/repo").first() } + val before = mock.backgroundJobsRequests.size + + val a = async { app.sessions.backgroundJobs("ses_root", "/repo").first() } + val b = async { app.sessions.backgroundJobs("ses_root", "/repo").first() } + withTimeout(10_000) { awaitAll(a, b) } + + assertEquals(1, mock.backgroundJobsRequests.size - before) + } + + @Test + fun `cancelBackgroundJob posts to the cancel route and parses the boolean body`() = runBlocking { + mock.backgroundJobCancelResult = "true" + val app = setup() + ready(app) + + val result = app.sessions.cancelBackgroundJob("job1", "/repo") + + assertTrue(result) + val path = mock.lastBackgroundJobCancelPath ?: error("missing cancel request") + assertTrue(path.startsWith("/kilocode/background-jobs/job1/cancel?"), "Expected cancel path, got $path") + assertTrue(URLDecoder.decode(path, "UTF-8").contains("directory=/repo"), path) + } + + @Test + fun `cancelBackgroundJob surfaces server failure`() = runBlocking { + mock.backgroundJobCancelStatus = 500 + val app = setup() + ready(app) + + val err = assertFailsWith { + app.sessions.cancelBackgroundJob("job1", "/repo") + } + assertTrue(err.message.orEmpty().contains("HTTP 500")) + } + + @Test + fun `promoteBackgroundJob posts to the promote route`() = runBlocking { + mock.backgroundJobPromoteResult = "true" + val app = setup() + ready(app) + + val result = app.sessions.promoteBackgroundJob("ses_child1", "/repo") + + assertTrue(result) + val path = mock.lastBackgroundJobPromotePath ?: error("missing promote request") + assertTrue(path.startsWith("/kilocode/background-jobs/ses_child1/promote?"), "Expected promote path, got $path") + } + + @Test + fun `promoteBackgroundJob returns false when the CLI kill switch is off`() = runBlocking { + mock.backgroundJobPromoteResult = "false" + val app = setup() + ready(app) + + val result = app.sessions.promoteBackgroundJob("ses_child1", "/repo") + + assertFalse(result) + } +} diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt index 91b694424da6..f37f5f6d9da4 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt @@ -2978,6 +2978,100 @@ class KiloCliDataParserTest { assertEquals("before\n\nafter\n\n\nkeep", KiloCliDataParser.sanitizeUserPromptText(text)) } + // ================================================================ + // parseBackgroundJobs + // ================================================================ + + @Nested + inner class BackgroundJobs { + @Test + fun `parseBackgroundJobs - flattens metadata into typed fields`() { + val raw = """ + [ + { + "id": "job1", + "type": "task", + "status": "running", + "title": "Explore", + "started_at": 1700000000000, + "metadata": { + "sessionId": "ses_child1", + "parentSessionId": "ses_parent", + "background": true + } + } + ] + """.trimIndent() + + val job = KiloCliDataParser.parseBackgroundJobs(raw).single() + + assertEquals("job1", job.id) + assertEquals("task", job.type) + assertEquals("running", job.status) + assertEquals("Explore", job.title) + assertEquals(1700000000000L, job.startedAt) + assertNull(job.completedAt) + assertNull(job.error) + assertEquals("ses_child1", job.sessionId) + assertEquals("ses_parent", job.parentSessionId) + assertTrue(job.background) + } + + @Test + fun `parseBackgroundJobs - defaults when metadata is absent`() { + val raw = """[{"id": "job1", "type": "task", "status": "completed"}]""" + + val job = KiloCliDataParser.parseBackgroundJobs(raw).single() + + assertEquals(0L, job.startedAt) + assertNull(job.completedAt) + assertNull(job.sessionId) + assertNull(job.parentSessionId) + assertFalse(job.background) + } + + @Test + fun `parseBackgroundJobs - tolerates non-finite timestamps from the widened SDK types`() { + val raw = """ + [ + { + "id": "job1", + "type": "task", + "status": "error", + "started_at": "NaN", + "completed_at": "Infinity", + "error": "boom" + } + ] + """.trimIndent() + + val job = KiloCliDataParser.parseBackgroundJobs(raw).single() + + assertEquals(0L, job.startedAt) + assertNull(job.completedAt) + assertEquals("boom", job.error) + } + + @Test + fun `parseBackgroundJobs - drops a row missing a required field instead of failing the list`() { + val raw = """ + [ + {"id": "job1", "type": "task", "status": "running"}, + {"id": "job2", "type": "task"} + ] + """.trimIndent() + + val jobs = KiloCliDataParser.parseBackgroundJobs(raw) + + assertEquals(listOf("job1"), jobs.map { it.id }) + } + + @Test + fun `parseBackgroundJobs - malformed json returns an empty list`() { + assertEquals(emptyList(), KiloCliDataParser.parseBackgroundJobs("not json")) + } + } + // ================================================================ // Helpers // ================================================================ 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 4fe6976b1a53..bf5889d0175b 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 @@ -80,6 +80,16 @@ class MockCliServer : AutoCloseable { @Volatile var lastSessionBoardPath: String? = null @Volatile var lastResetSessionBoardPath: String? = null @Volatile var lastResetSessionBoardBody: String? = null + @Volatile var backgroundJobs = "[]" + @Volatile var backgroundJobsStatus = 200 + @Volatile var backgroundJobCancelResult = "true" + @Volatile var backgroundJobCancelStatus = 200 + @Volatile var backgroundJobPromoteResult = "true" + @Volatile var backgroundJobPromoteStatus = 200 + @Volatile var lastBackgroundJobsPath: String? = null + @Volatile var lastBackgroundJobCancelPath: String? = null + @Volatile var lastBackgroundJobPromotePath: String? = null + val backgroundJobsRequests = java.util.concurrent.CopyOnWriteArrayList() @Volatile var lastCommandRemoveBody: String? = null @Volatile var lastSkillRemoveBody: String? = null @Volatile var lastAgentBuilderPath: String? = null @@ -183,6 +193,9 @@ class MockCliServer : AutoCloseable { /** Optional gate for config warnings only. */ @Volatile var warningsGate: CountDownLatch? = null + /** Holds `/experimental/capabilities` so a test can simulate a hung optional probe. */ + @Volatile var capabilitiesGate: CountDownLatch? = null + /** Request counts by bare path (e.g. "/session" or "/global/config"). Thread-safe. */ private val counts = ConcurrentHashMap() private val requests = Object() @@ -220,6 +233,9 @@ class MockCliServer : AutoCloseable { } @Volatile var lastExperimentalSessionPath: String? = null + @Volatile var lastCapabilitiesPath: String? = null + @Volatile var capabilities = """{"backgroundSubagents":true}""" + @Volatile var capabilitiesStatus = 200 /** Reset all request counters. */ fun resetCounts() { counts.clear() } @@ -351,6 +367,7 @@ class MockCliServer : AutoCloseable { if (delay > 0) Thread.sleep(delay) if (bare != "/global/event") responseGate?.await() if (bare.startsWith("/config/warnings")) warningsGate?.await() + if (bare == "/experimental/capabilities") capabilitiesGate?.await() when { path == "/global/health" -> respond(output, 200, health) @@ -443,6 +460,19 @@ class MockCliServer : AutoCloseable { lastResetSessionBoardBody = body respond(output, resetSessionBoardStatus, resetSessionBoardResponse) } + bare == "/kilocode/background-jobs" && method == "GET" -> { + lastBackgroundJobsPath = path + backgroundJobsRequests.add(path) + respond(output, backgroundJobsStatus, backgroundJobs) + } + bare.matches(Regex("/kilocode/background-jobs/[^/]+/cancel")) && method == "POST" -> { + lastBackgroundJobCancelPath = path + respond(output, backgroundJobCancelStatus, backgroundJobCancelResult) + } + bare.matches(Regex("/kilocode/background-jobs/[^/]+/promote")) && method == "POST" -> { + lastBackgroundJobPromotePath = path + respond(output, backgroundJobPromoteStatus, backgroundJobPromoteResult) + } bare == "/instance/reload" && method == "POST" -> respond(output, 200, "true") bare == "/command" -> respond(output, commandsStatus, commands) bare == "/skill" -> respond(output, skillsStatus, skills) @@ -464,6 +494,10 @@ class MockCliServer : AutoCloseable { lastExperimentalSessionPath = path respond(output, recentSessionsStatus, recentSessions) } + bare == "/experimental/capabilities" -> { + lastCapabilitiesPath = path + respond(output, capabilitiesStatus, capabilities) + } bare == "/kilo/cloud-sessions" -> { lastCloudSessionsPath = path respond(output, cloudSessionsStatus, cloudSessions) 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 46aaa4b87bce..e8e1864e225b 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 @@ -6,6 +6,7 @@ import ai.kilocode.log.ChatLogSummary import ai.kilocode.rpc.KiloSessionRpcApi import ai.kilocode.client.session.SessionActivityKind import ai.kilocode.client.session.toKind +import ai.kilocode.rpc.dto.BackgroundJobDto import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.CloudSessionListDto import ai.kilocode.rpc.dto.DiffFileDto @@ -400,6 +401,37 @@ class KiloSessionService internal constructor( } } + // ------ background subagents ------ + + /** Observe background subagent jobs owned by root session [id]. */ + fun backgroundJobs(id: String, dir: String): Flow> { + val api = rpc + val jobs = if (api != null) flow { + api.backgroundJobs(id, dir).collect { emit(it) } + } else flow { + durable { + KiloSessionRpcApi.getInstance().backgroundJobs(id, dir).collect { emit(it) } + } + } + return jobs + .onStart { log.debug { "${ChatLogSummary.sid(id)} kind=subscription route=background-jobs start=true dir=${ChatLogSummary.dir(dir)}" } } + .onCompletion { cause -> + if (cause == null || cause is CancellationException) { + log.debug { "${ChatLogSummary.sid(id)} kind=subscription route=background-jobs stop=true cancelled=${cause is CancellationException}" } + return@onCompletion + } + log.warn("${ChatLogSummary.sid(id)} kind=subscription route=background-jobs stop=true failed message=${cause.message}", cause) + } + } + + /** Cancel one background subagent job and its child session tree. */ + suspend fun cancelBackgroundJob(id: String, dir: String): Boolean = + call { cancelBackgroundJob(id, dir) } + + /** Continue one foreground subagent job in the background. Returns false when the CLI's background-subagent kill switch is off. */ + suspend fun promoteBackgroundJob(id: String, dir: String): Boolean = + call { promoteBackgroundJob(id, dir) } + // ------ permission / question resolution ------ /** Reply to a pending permission request. */ diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/AgentAvatar.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/AgentAvatar.kt new file mode 100644 index 000000000000..34ec08d19b32 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/AgentAvatar.kt @@ -0,0 +1,192 @@ +package ai.kilocode.client.session + +import ai.kilocode.client.ui.UiStyle +import com.intellij.ui.AnimatedIcon +import com.intellij.ui.ColorUtil +import com.intellij.util.ui.JBUI +import java.awt.Color +import java.awt.Component +import java.awt.Graphics +import java.awt.Graphics2D +import java.awt.RenderingHints +import java.awt.geom.Ellipse2D +import javax.swing.Icon +import kotlin.math.cos + +/** + * Deterministic per-subagent identity, ported field-for-field from the web client's generated + * avatar so a subagent reads with the same recognizable shape and color slot in JetBrains as it + * does in VS Code. See `packages/kilo-ui/src/components/agent-avatar-identity.ts` for the source of + * truth; keep this object in lock-step with that file rather than reinterpreting the algorithm. + */ +internal object AgentAvatarIdentity { + const val COLORS = 8 + private const val HALF = 15 + + // MIN..MAX connected half-cells grown per identity (web: MIN=6, MAX=9). + private const val MIN = 6 + private const val SPAN = 4 + + private val FNV_PRIME = 16777619u + private val SHAPE_SEED = 2166136261u + private val TONE_SEED = 0x9747b28cu + private val RANK_MULTIPLIER = 0x27d4eb2du + + /** [color] is the identity's preferred hue slot, or null for the neutral "unknown" glyph. */ + data class Identity(val color: Int?, val cells: Set) + + /** + * Assigns sibling colors in spawn order: each id keeps its hashed hue while free, otherwise the + * next free hue, so the first [COLORS] siblings never share a color. After that, colors repeat + * and the glyph shape is what tells siblings apart. Mirrors `palette()` in + * `agent-avatar-identity.ts`. + */ + fun palette(ids: List): Map { + val used = mutableSetOf() + val result = LinkedHashMap() + for (id in ids) { + if (result.containsKey(id)) continue + val color = identity(id).color ?: continue + if (used.size >= COLORS) used.clear() + val pick = (0 until COLORS).map { (color + it) % COLORS }.firstOrNull { it !in used } ?: continue + used.add(pick) + result[id] = pick + } + return result + } + + /** Mirrors `identity()` in `agent-avatar-identity.ts`. */ + fun identity(id: String): Identity { + val known = id.trim().isNotEmpty() && id != "unknown" + if (!known) { + val neutral = setOf(4, 7, 10, 13) + return Identity(null, expand { it in neutral }) + } + val shape = fnv(id, SHAPE_SEED) + val tone = fnv(id, TONE_SEED) + val count = MIN + (tone % SPAN.toUInt()).toInt() + fun rank(index: Int): UInt = (shape xor (index + 1).toUInt()) * RANK_MULTIPLIER + val lit = mutableSetOf(2 + (shape % 5u).toInt() * 3) + while (lit.size < count) { + val edge = (0 until HALF).filter { index -> + if (index in lit) return@filter false + if (index == 0 || index == 12) return@filter false + val row = index / 3 + val col = index % 3 + (col > 0 && (index - 1) in lit) || + (col < 2 && (index + 1) in lit) || + (row > 0 && (index - 3) in lit) || + (row < 4 && (index + 3) in lit) + } + val next = edge.reduce { best, index -> if (rank(index) > rank(best)) index else best } + lit.add(next) + } + val color = ((tone shr 4) % COLORS.toUInt()).toInt() + return Identity(color, expand { it in lit }) + } + + /** 32-bit FNV-1a-style hash, matching the JS `Math.imul` + `>>> 0` unsigned overflow semantics. */ + private fun fnv(input: String, seed: UInt): UInt { + var hash = seed + for (ch in input) hash = (hash xor ch.code.toUInt()) * FNV_PRIME + return hash + } + + /** + * Mirrors three half-grid columns into a five-column grid, like [SpinnerIcon]'s 4x4 grid. [half] + * is indexed on a 5-row, 3-column grid (0..14); column `c` maps to full-grid columns `c` and + * `4 - c`, so column 2 only maps to the center column. + */ + private fun expand(half: (Int) -> Boolean): Set = (0 until 25).filter { index -> + val x = index % 5 + half((index / 5) * 3 + minOf(x, 4 - x)) + }.toSet() +} + +/** + * Renders [AgentAvatarIdentity] as an inline row icon: a 5x5 dot grid with the four corners + * removed, matching the web client's generated avatar (see + * `packages/kilo-ui/src/components/agent-avatar.tsx` and `agent-avatar.css`). The static icon shows + * the identity at rest; the running icon additionally pulses the faint surrounding dots so the + * recognizable lit glyph stays solid while the subagent is active — never the only signal of state, + * since callers keep their own adjacent status text/icon. + * + * Built on [com.intellij.ui.AnimatedIcon], the same platform pattern [SpinnerIcon] uses, so it + * animates automatically inside list cell renderers that opt in via + * `AnimatedIcon.ANIMATION_IN_RENDERER_ALLOWED` and needs no timer, coroutine, or disposal. + */ +internal object AgentAvatar { + private val CORNERS = setOf(0, 4, 20, 24) + private val GRID = (0 until 25).filter { it !in CORNERS } + private const val VIEWBOX = 19f + private const val PITCH = 4f + private const val RADIUS = 1.5f + + private const val FRAMES = 14 + private const val CYCLE_MS = 1400 + private val DELAY = CYCLE_MS / FRAMES + private const val UNLIT_LO = 0.1 + private const val UNLIT_HI = 0.45 + private const val LIT_ALPHA = 1.0 + private const val STATIC_UNLIT_ALPHA = 0.1 + + /** Static glyph for [id], used at rest (pending/completed/error/cancelled). */ + fun static(id: String, color: Int? = null): Icon { + val identity = AgentAvatarIdentity.identity(id) + return Frame(identity.cells, color ?: identity.color, frame = null) + } + + /** Animated glyph for [id], used while the subagent is actively running. */ + fun running(id: String, color: Int? = null): Icon { + val identity = AgentAvatarIdentity.identity(id) + val resolved = color ?: identity.color + return AnimatedIcon(DELAY, *Array(FRAMES) { Frame(identity.cells, resolved, it) }) + } + + /** Ease-in-out 0..1..0 wave, matching the web client's CSS `ease-in-out` opacity pulses. */ + private fun ease(t: Float): Float = ((1f - cos(2.0 * Math.PI * t)) / 2.0).toFloat() + + /** + * Per-cell phase fraction, matching the web client's `-(((cell * 7) % 11) / 11) * 1.4s` negative + * animation-delay: a negative delay shifts the effective local time forward by the same amount, + * so each cell's progress is `frame/FRAMES + phase(cell)`. + */ + private fun phase(cell: Int): Float = ((cell * 7) % 11) / 11f + + private fun colorFor(index: Int?): Color = if (index == null) UiStyle.Colors.weak() else UiStyle.Colors.avatarHue(index) + + private class Frame( + private val cells: Set, + private val color: Int?, + private val frame: Int?, + ) : Icon { + override fun getIconWidth() = JBUI.scale(16) + + override fun getIconHeight() = JBUI.scale(16) + + override fun paintIcon(c: Component?, g: Graphics, x: Int, y: Int) { + val g2 = g.create() as Graphics2D + try { + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + g2.translate(x, y) + val scale = iconWidth / VIEWBOX + val base = colorFor(color) + val diameter = RADIUS * 2f * scale + for (cell in GRID) { + val lit = cell in cells + val alpha = when { + lit -> LIT_ALPHA + frame == null -> STATIC_UNLIT_ALPHA + else -> UNLIT_LO + (UNLIT_HI - UNLIT_LO) * ease(frame.toFloat() / FRAMES + phase(cell)) + } + g2.color = ColorUtil.withAlpha(base, alpha) + val cx = ((cell % 5) * PITCH + RADIUS) * scale + val cy = ((cell / 5) * PITCH + RADIUS) * scale + g2.fill(Ellipse2D.Float(cx - diameter / 2f, cy - diameter / 2f, diameter, diameter)) + } + } finally { + g2.dispose() + } + } + } +} 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 03b373c4c671..80f04229a78c 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 @@ -69,6 +69,7 @@ import ai.kilocode.client.session.controller.SessionController import ai.kilocode.client.session.controller.SessionControllerEvent import ai.kilocode.client.session.context.EditorContextGatherer import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.session.views.BackgroundPromote import ai.kilocode.client.session.views.LoginRequiredView import ai.kilocode.client.session.views.SessionOutcomeView import ai.kilocode.client.session.views.permission.PermissionView @@ -547,12 +548,23 @@ class SessionUi( deleteQueued = if (readonly) null else { id -> controller.deleteQueuedMessage(id) }, banner = if (readonly) null else RevertBanner(controller.model, ::redo, controller::redoAll, ::cancelRevert, focus), onOpenSubagent = ::openSubagent, + onPromoteBackgroundAgent = if (readonly) null else BackgroundPromote( + available = { app.state.value.backgroundSubagents }, + promote = controller::promoteBackgroundAgent, + ), ).also { it.outcome = outcome it.setDiffOpener(::openInlineDiff, controller.id) it.onHover = { view, on -> if (on) popup.show(view) else popup.notifyExit(view) } } - header = SessionHeaderPanel(controller, this, readonly, boardVisible = { board }, onShowBoard = ::showBoard) + header = SessionHeaderPanel( + controller, + this, + readonly, + boardVisible = { board }, + onShowBoard = ::showBoard, + onOpenSubagent = ::openSubagent, + ) if (!readonly && showBranchDock()) { val owner = manager val newWorktree = if (owner?.supportsNewWorktree == true) owner::newWorktree else null @@ -815,6 +827,7 @@ class SessionUi( is SessionModelEvent.ContentRemoved, is SessionModelEvent.DiffUpdated, is SessionModelEvent.TodosUpdated, + is SessionModelEvent.BackgroundAgentsUpdated, is SessionModelEvent.HeaderUpdated, is SessionModelEvent.Compacted -> Unit } @@ -1139,7 +1152,8 @@ class SessionUi( @RequiresEdt private fun openSubagent(sessionId: String, title: String) { - service().put(sessionId, title) + val color = AgentAvatarIdentity.palette(controller.model.childSessions())[sessionId] + service().put(sessionId, title, color) ensureSubagentSessionEditorKind() project.service().open( SubagentSessionEditorKind.ID, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/background/BackgroundAgent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/background/BackgroundAgent.kt new file mode 100644 index 000000000000..b63449459470 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/background/BackgroundAgent.kt @@ -0,0 +1,21 @@ +package ai.kilocode.client.session.background + +/** Lifecycle of a background subagent job, mirroring [ai.kilocode.rpc.dto.BackgroundJobDto.status]. */ +enum class BackgroundAgentStatus { RUNNING, COMPLETED, CANCELLED, ERROR } + +/** + * One row in the background-agents strip, derived from a [ai.kilocode.rpc.dto.BackgroundJobDto]. + * + * [job] is the job id — the cancel/dismiss key. [session] is the child session id — the key used to + * open its transcript. For a `task`-tool job the CLI uses the child session id as the job id, so the + * two are equal in practice, but they are kept distinct to match the wire shape. + */ +data class BackgroundAgent( + val job: String, + val session: String, + val title: String?, + val status: BackgroundAgentStatus, + val error: String? = null, + /** True when [session] has a pending permission request. See [BackgroundAgents.rows]. */ + val waiting: Boolean = false, +) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/background/BackgroundAgents.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/background/BackgroundAgents.kt new file mode 100644 index 000000000000..7ca362cdd0ff --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/background/BackgroundAgents.kt @@ -0,0 +1,56 @@ +package ai.kilocode.client.session.background + +import ai.kilocode.rpc.dto.BackgroundJobDto + +/** + * Pure derivation for the background-agents strip. Platform-free and RPC-free by design — the + * strip's Swing view must never touch these lists except through [ai.kilocode.client.session.model.SessionModel]. + * + * Mirrors `packages/kilo-vscode/webview-ui/src/components/chat/background-agents.ts`. + */ +object BackgroundAgents { + + /** + * Background subagent rows owned by root session [parent]. + * + * The CLI's list route already filters by `parentSessionId == parent`; the check is repeated + * here defensively, matching the VS Code webview. Only jobs with `type == "task"` and + * `metadata.background == true` count — a foreground task in progress is not a background + * agent yet. [waiting] is the set of child session ids with a pending permission request; a + * child session id with no matching job is ignored. + */ + fun rows(jobs: List, parent: String, waiting: Set = emptySet()): List = + jobs + .filter { it.type == "task" && it.parentSessionId == parent && it.background } + .map { job -> + val session = job.sessionId ?: job.id + BackgroundAgent( + job = job.id, + session = session, + title = job.title, + status = status(job.status), + error = job.error, + waiting = session in waiting, + ) + } + + /** + * A running agent always shows; a finished one only shows until dismissed. A dismissed job that + * starts running again (e.g. after a resume) reappears, because dismissal is not durable state. + */ + fun visible(agent: BackgroundAgent, dismissed: Set): Boolean = + agent.status == BackgroundAgentStatus.RUNNING || agent.job !in dismissed + + /** Active agents (running, or finished but awaiting input) first, stable order within each group. */ + fun order(agents: List): List { + val (active, rest) = agents.partition { it.status == BackgroundAgentStatus.RUNNING || it.waiting } + return active + rest + } + + private fun status(raw: String): BackgroundAgentStatus = when (raw) { + "running" -> BackgroundAgentStatus.RUNNING + "cancelled" -> BackgroundAgentStatus.CANCELLED + "error" -> BackgroundAgentStatus.ERROR + else -> BackgroundAgentStatus.COMPLETED + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/board/BoardAvatars.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/board/BoardAvatars.kt index 38beb19ad181..99322bdefa1f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/board/BoardAvatars.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/board/BoardAvatars.kt @@ -1,69 +1,29 @@ package ai.kilocode.client.session.board -import ai.kilocode.client.ui.UiStyle -import com.intellij.util.ui.JBFont -import com.intellij.util.ui.JBUI -import java.awt.Component -import java.awt.Graphics -import java.awt.Graphics2D -import java.awt.RenderingHints -import java.awt.geom.Ellipse2D -import java.util.concurrent.ConcurrentHashMap +import ai.kilocode.client.session.AgentAvatar +import ai.kilocode.client.session.AgentAvatarIdentity +import ai.kilocode.client.session.views.SessionViewIcons import javax.swing.Icon /** - * Deterministic per-participant avatar for the shared agent board: a filled circle with the - * participant's initial, colored by its position in the board's participant order (`main` first, - * then child sessions in spawn order — see + * Per-dialog generated avatar cache for the shared agent board. `main` and `ALL` are not subagents, + * so they get the ordinary task glyph; every other participant gets the same deterministic dot-glyph + * identity used on task cards and background-agent rows (see + * [ai.kilocode.client.session.AgentAvatar]), colored by its position in [order] (the board's + * participant order — `main` first, then child sessions in spawn order, see * [ai.kilocode.client.session.model.SessionModel.childSessions]). + * + * Scoped to one [SessionBoardDialog] instance rather than a global object: the board is short-lived + * and its participant set is small, so caching by participant id here cannot grow past the dialog's + * own lifetime the way a static, application-wide cache would. */ -internal object BoardAvatars { - /** - * Keyed by what the icon actually draws — its initial and colour slot — not by participant id. - * Session ids never repeat, so keying on them would grow this map for the life of the IDE; the - * glyph space is bounded by (alphanumeric initials x colour slots) and reuses entries instead. - */ - private val cache = ConcurrentHashMap, Icon>() +internal class BoardAvatars(order: List) { + private val palette = AgentAvatarIdentity.palette(order.filterNot { it == "main" || it == "ALL" }) + private val cache = HashMap() - /** - * [id]'s avatar. [order] is the board's participant order; [id]'s 0-based position in it picks a - * stable colour. `main`, `ALL`, and any id absent from [order] get the neutral fill. - */ - fun icon(id: String, order: List): Icon { - val index = if (id == "main" || id == "ALL") -1 else order.indexOf(id) - val initial = initial(id) - return cache.computeIfAbsent(initial to index) { AvatarIcon(initial.toString(), index) } - } - - private fun initial(id: String): Char = - (id.firstOrNull { it.isLetter() || it.isDigit() } ?: '?').uppercaseChar() - - /** - * [index] is the participant's colour slot, resolved to a theme colour at paint time rather than - * captured, so a cached icon still follows a Look and Feel change. A negative slot is neutral. - */ - private class AvatarIcon(private val text: String, private val index: Int) : Icon { - override fun getIconWidth() = JBUI.scale(16) - override fun getIconHeight() = JBUI.scale(16) - - override fun paintIcon(c: Component?, g: Graphics, x: Int, y: Int) { - val g2 = g.create() as Graphics2D - try { - g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) - g2.translate(x, y) - g2.color = if (index < 0) UiStyle.Colors.swarmAvatarMain() else UiStyle.Colors.swarmAvatar(index) - val inset = JBUI.scale(1).toFloat() - val size = iconWidth - inset * 2 - g2.fill(Ellipse2D.Float(inset, inset, size, size)) - g2.color = UiStyle.Colors.swarmAvatarForeground() - g2.font = JBFont.small().asBold() - val fm = g2.fontMetrics - val width = fm.stringWidth(text) - val base = (iconHeight + fm.ascent - fm.descent) / 2 - g2.drawString(text, (iconWidth - width) / 2, base) - } finally { - g2.dispose() - } - } + /** [id]'s avatar. Always static — the board shows history, not live run state. */ + fun icon(id: String): Icon { + if (id == "main" || id == "ALL") return SessionViewIcons.task + return cache.getOrPut(id) { AgentAvatar.static(id, palette[id]) } } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/board/SessionBoardDialog.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/board/SessionBoardDialog.kt index b747b98b341d..d58ce2c4af05 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/board/SessionBoardDialog.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/board/SessionBoardDialog.kt @@ -45,7 +45,7 @@ import kotlinx.coroutines.launch * * [order] is the board's participant order (`main` first, then child sessions in spawn order — see * `ai.kilocode.client.session.model.SessionModel.childSessions`), used to give each participant a - * stable, distinct avatar via [BoardAvatars]. + * stable, distinct avatar via [avatars]. */ internal class SessionBoardDialog( parent: Component, @@ -59,6 +59,7 @@ internal class SessionBoardDialog( ) : DialogWrapper(parent, false) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val avatars = BoardAvatars(order) private var disposed = false private var loading = false private var board: SessionBoardDto? = null @@ -262,7 +263,7 @@ internal class SessionBoardDialog( key = message.id, title = "$from \u2192 $to", description = message.body.replace("\n", " "), - icon = BoardAvatars.icon(message.from, order), + icon = avatars.icon(message.from), badges = listOf(ActiveListBadge(message.type)), participant = message.from, participantLabel = message.fromLabel, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt index c5223bc0f583..8a844ac07de9 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt @@ -5,6 +5,7 @@ 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.background.BackgroundAgents import ai.kilocode.client.session.model.AgentItem import ai.kilocode.client.session.model.ModelLimitItem import ai.kilocode.client.session.model.ModelItem @@ -34,6 +35,7 @@ import ai.kilocode.client.util.UiTimerSource import ai.kilocode.client.util.UiTimers import ai.kilocode.client.util.edtLater as edt import ai.kilocode.rpc.dto.AgentsDto +import ai.kilocode.rpc.dto.BackgroundJobDto import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.ConfigWarningDto import ai.kilocode.rpc.dto.EditorContextDto @@ -109,6 +111,7 @@ class SessionController( private val openProfileAction: () -> Unit = {}, private val telemetry: (String, Map) -> Unit = { event, props -> Telemetry.send(event, props) }, private val notify: (String, String) -> Unit = { title, body -> KiloNotifications.error(title, body) }, + private val notifyInfo: (String, String) -> Unit = { title, body -> KiloNotifications.info(title, body) }, private val timers: UiTimerSource = UiTimers, private val log: KiloLog = LOG, ) : Disposable { @@ -162,6 +165,8 @@ class SessionController( private var partType: String? = null private var tool: String? = null private var eventJob: Job? = null + private var backgroundJobsJob: Job? = null + private var backgroundJobsRaw: List = emptyList() private var drainJob: Job? = null private var revertJob: Job? = null private var revertOp: RevertOp? = null @@ -1365,6 +1370,20 @@ class SessionController( LOG.debug { "${ChatLogSummary.sid(id)} kind=subscription subscribe=false" } } } + backgroundJobsJob = cs.launch { + try { + sessions.backgroundJobs(id, directory).collect { jobs -> + edt { + if (disposed || sid != id) return@edt + updateModel { applyBackgroundJobs(jobs) } + } + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + log.warn("${ChatLogSummary.sid(id)} kind=subscription route=background-jobs failed message=${e.message}", e) + } + } } @RequiresEdt @@ -1429,6 +1448,9 @@ class SessionController( assertEdt() eventJob?.cancel() eventJob = null + backgroundJobsJob?.cancel() + backgroundJobsJob = null + backgroundJobsRaw = emptyList() childJobs.values.forEach { it.cancel() } childJobs.clear() childIds.clear() @@ -1436,6 +1458,84 @@ class SessionController( pending.clear() } + /** Apply a fresh background-jobs list from the CLI. Must run inside [updateModel]. */ + @RequiresEdt + private fun applyBackgroundJobs(jobs: List) { + backgroundJobsRaw = jobs + reapplyBackgroundAgents() + } + + /** + * Re-derive [SessionModel.backgroundAgents] from the last jobs list and the current pending + * permissions. Called both when a fresh jobs list arrives and whenever [pending] changes for a + * child session — the backend's job-list poll only re-emits when the *job* itself changes, not + * when a permission is asked or answered, so a "needs input" badge would otherwise go stale + * until the next unrelated job transition. + * + * Only permission waits are tracked: child *questions* are not routed through [pending] at all + * (see [isChildEvent]), so a child awaiting a question does not yet surface "needs input" here. + */ + @RequiresEdt + private fun reapplyBackgroundAgents() { + val id = sid ?: return + val waiting = pending.values.mapTo(mutableSetOf()) { it.sessionId } + model.setBackgroundAgents(BackgroundAgents.rows(backgroundJobsRaw, id, waiting)) + } + + /** Cancel one background subagent job and its child session tree. */ + @RequiresEdt + fun cancelBackgroundAgent(job: String) { + assertEdt() + val dir = directory + cs.launch { + try { + sessions.cancelBackgroundJob(job, dir) + capture("Background Agent Stopped", mapOf("job" to job)) + } catch (e: Exception) { + LOG.warn("${ChatLogSummary.sid(sid ?: "pending")} kind=background-agent cancel=true job=$job failed message=${e.message}", e) + } + } + } + + /** Hide finished background-agent rows [jobs] locally. Never deletes the child session or job record. */ + @RequiresEdt + fun dismissBackgroundAgents(jobs: Set) { + assertEdt() + if (jobs.isEmpty()) return + updateModel { model.dismissBackgroundAgents(jobs) } + } + + /** + * Continue foreground task [job] (its child session id) in the background. Returns immediately; + * the strip picks up the promoted job on the next background-jobs poll. Surfaces a notification + * when the CLI's background-subagent kill switch is off, since the caller cannot tell from a + * fire-and-forget UI click. + */ + @RequiresEdt + fun promoteBackgroundAgent(job: String) { + assertEdt() + val dir = directory + cs.launch { + val promoted = try { + sessions.promoteBackgroundJob(job, dir) + } catch (e: Exception) { + LOG.warn("${ChatLogSummary.sid(sid ?: "pending")} kind=background-agent promote=true job=$job failed message=${e.message}", e) + false + } + if (promoted) { + capture("Background Agent Promoted", mapOf("job" to job)) + return@launch + } + edt { + if (disposed) return@edt + notifyInfo( + KiloBundle.message("session.header.agents.disabledTitle"), + KiloBundle.message("session.header.agents.disabledMessage"), + ) + } + } + } + private suspend fun recoverChildPermissions(child: String) { try { val permissions = sessions.pendingPermissions(directory).filter { it.sessionID == child } @@ -1870,15 +1970,18 @@ class SessionController( private fun asked(event: ChatEventDto.PermissionAsked) { if (autoApprove) { approve(event.request) + reapplyBackgroundAgents() return } show(toPermission(event.request)) + reapplyBackgroundAgents() } private fun replied(event: ChatEventDto.PermissionReplied) { val current = model.state val front = current is SessionState.AwaitingPermission && current.permission.id == event.requestID pending.remove(event.requestID) + reapplyBackgroundAgents() // Front card resolved: advance to the next queued permission, else resume Busy. if (front) { model.setState(afterResolve()) @@ -1947,6 +2050,7 @@ class SessionController( if (session == null) return val removed = pending.entries.removeIf { it.value.sessionId == session } if (!removed) return + reapplyBackgroundAgents() val current = model.state if (current is SessionState.AwaitingPermission && current.permission.sessionId == session) { model.setState(afterResolve(idle = true)) 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 d59236858927..09970c516c9e 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 @@ -1,5 +1,7 @@ package ai.kilocode.client.session.model +import ai.kilocode.client.session.background.BackgroundAgent +import ai.kilocode.client.session.background.BackgroundAgents import ai.kilocode.rpc.dto.DiffFileDto import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.KiloAppStatusDto @@ -87,6 +89,13 @@ class SessionModel { var todos: List = emptyList() private set + /** Already dismissal-filtered, already-ordered rows for the background-agents strip. */ + var backgroundAgents: List = emptyList() + private set + + private var rawBackgroundAgents: List = emptyList() + private var dismissedBackgroundAgents: Set = emptySet() + var compactionCount: Int = 0 private set @@ -332,6 +341,30 @@ class SessionModel { updateHeader() } + /** Replace the raw background-agent rows (already parent-filtered by the caller) and re-derive [backgroundAgents]. */ + @RequiresEdt + fun setBackgroundAgents(agents: List) { + rawBackgroundAgents = agents + applyBackgroundAgents() + } + + /** Hide finished background-agent rows locally. A dismissed job that runs again reappears. */ + @RequiresEdt + fun dismissBackgroundAgents(jobs: Set) { + if (jobs.isEmpty()) return + dismissedBackgroundAgents = dismissedBackgroundAgents + jobs + applyBackgroundAgents() + } + + private fun applyBackgroundAgents() { + val visible = BackgroundAgents.order( + rawBackgroundAgents.filter { BackgroundAgents.visible(it, dismissedBackgroundAgents) }, + ) + if (visible == backgroundAgents) return + backgroundAgents = visible + fire(SessionModelEvent.BackgroundAgentsUpdated(visible)) + } + @RequiresEdt fun markCompacted() { compactionCount++ @@ -357,6 +390,9 @@ class SessionModel { state = SessionState.Idle diff = emptyList() todos = emptyList() + rawBackgroundAgents = emptyList() + dismissedBackgroundAgents = emptySet() + backgroundAgents = emptyList() compactionCount = 0 for (msg in history) { val item = Message(msg.info) @@ -392,6 +428,9 @@ class SessionModel { state = SessionState.Idle diff = emptyList() todos = emptyList() + rawBackgroundAgents = emptyList() + dismissedBackgroundAgents = emptySet() + backgroundAgents = emptyList() compactionCount = 0 fire(SessionModelEvent.Cleared) updateHeader() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt index d3af00f63883..72db86f3a202 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.session.model +import ai.kilocode.client.session.background.BackgroundAgent import ai.kilocode.rpc.dto.DiffFileDto import ai.kilocode.rpc.dto.SessionDto import ai.kilocode.rpc.dto.SessionRevertDto @@ -54,6 +55,9 @@ sealed class SessionModelEvent { data class TodosUpdated(val todos: List) : SessionModelEvent() { override fun toString() = "TodosUpdated count=${todos.size}" } + data class BackgroundAgentsUpdated(val agents: List) : SessionModelEvent() { + override fun toString() = "BackgroundAgentsUpdated count=${agents.size}" + } data class SessionUpdated(val session: SessionDto) : SessionModelEvent() { override fun toString() = "SessionUpdated ${session.id}" } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/subagent/SubagentSessionEditorKind.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/subagent/SubagentSessionEditorKind.kt index fa32525f949d..290f767927b4 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/subagent/SubagentSessionEditorKind.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/subagent/SubagentSessionEditorKind.kt @@ -4,6 +4,7 @@ import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloSessionService import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.AgentAvatar import ai.kilocode.client.session.SessionUi import ai.kilocode.client.session.SessionUiFactory import ai.kilocode.client.vfs.KiloEditorKind @@ -32,7 +33,16 @@ object SubagentSessionEditorKind : KiloEditorKind { ?: KiloBundle.message("session.subagent.title") } - override fun icon(params: Map): Icon = AllIcons.Nodes.Function + /** + * Static, ID-derived identity (see [ai.kilocode.client.session.AgentAvatar]) so a read-only + * subagent tab reads with the same shape/hue as its task card and background-agent row. Falls + * back to a generic icon only when the tab has no session id at all. + */ + override fun icon(params: Map): Icon { + val id = params[SESSION]?.takeIf { it.isNotBlank() } ?: return AllIcons.Nodes.Function + val color = service().color(id) + return AgentAvatar.static(id, color) + } override fun presentablePath(params: Map): String = KiloBundle.message("session.subagent.path", params[SESSION].orEmpty()) override fun isValid(params: Map): Boolean = !params[SESSION].isNullOrBlank() && !params[DIR].isNullOrBlank() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/subagent/SubagentTitleCache.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/subagent/SubagentTitleCache.kt index 7542ba643a25..cf18cb667a36 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/subagent/SubagentTitleCache.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/subagent/SubagentTitleCache.kt @@ -5,23 +5,41 @@ import com.intellij.util.concurrency.annotations.RequiresEdt private const val CAP = 128 +/** + * Presentation hints for a subagent session's editor tab: its display title (from the message that + * opened it) and its sibling avatar hue (see [ai.kilocode.client.session.AgentAvatar]), captured at + * [ai.kilocode.client.session.SessionUi.openSubagent] time. Neither value is part of the VFS + * identity ([subagentSessionParams] stays `sessionId` + `directory` only), so a restored tab without + * a cache entry simply falls back to the generic title and the identity's own hashed hue. + */ +internal data class SubagentPresentation(val title: String, val color: Int? = null) + @Service(Service.Level.APP) class SubagentTitleCache { - // Access-order LRU so high-churn sub-agent session ids evict oldest-used first. - private val names = object : LinkedHashMap(16, 0.75f, true) { - override fun removeEldestEntry(eldest: MutableMap.MutableEntry) = size > CAP + private val lock = Any() + + // Access-order LRU so high-churn sub-agent session ids evict oldest-used first. Access-order + // reorders entries on every read as well as every write, so every access — not just `put` — must + // go through `lock`: `title()` backs `KiloVirtualFile.getName()` and `color()` backs + // `KiloFileIconProvider`, both of which the platform can call from a background thread. + private val entries = object : LinkedHashMap(16, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry) = size > CAP } + /** Only ever called from [ai.kilocode.client.session.SessionUi.openSubagent], which is EDT-bound. */ @RequiresEdt - fun put(sessionId: String, title: String) { - names[sessionId] = title + fun put(sessionId: String, title: String, color: Int? = null) { + synchronized(lock) { entries[sessionId] = SubagentPresentation(title, color) } } - @RequiresEdt - fun title(sessionId: String): String? = names[sessionId] + /** Safe off the EDT: [ai.kilocode.client.vfs.KiloVirtualFile.getName] is not EDT-restricted. */ + fun title(sessionId: String): String? = synchronized(lock) { entries[sessionId]?.title } + + /** Safe off the EDT: [ai.kilocode.client.vfs.KiloFileIconProvider] may run on a background thread. */ + fun color(sessionId: String): Int? = synchronized(lock) { entries[sessionId]?.color } @RequiresEdt fun clear() { - names.clear() + synchronized(lock) { entries.clear() } } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt index d6e0aac46f26..7a3298a10c82 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt @@ -12,6 +12,7 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.session.views.BackgroundPromote import ai.kilocode.client.session.views.LoginRequiredView import ai.kilocode.client.session.views.MessageView import ai.kilocode.client.session.views.SessionOutcomeView @@ -70,6 +71,7 @@ class SessionMessageListPanel( private val deleteQueued: ((String) -> Unit)? = null, private val banner: RevertBanner? = null, private val onOpenSubagent: ((String, String) -> Unit)? = null, + private val onPromoteBackgroundAgent: BackgroundPromote? = null, ) : SessionLayoutPanel( SessionUiStyle.SessionLayout.GAP, Insets( @@ -186,6 +188,7 @@ class SessionMessageListPanel( is SessionModelEvent.MessageAdded, is SessionModelEvent.MessageRemoved, is SessionModelEvent.TodosUpdated, + is SessionModelEvent.BackgroundAgentsUpdated, is SessionModelEvent.SessionUpdated, is SessionModelEvent.HeaderUpdated, is SessionModelEvent.Compacted -> Unit @@ -308,10 +311,20 @@ class SessionMessageListPanel( return after != before } + /** + * Sibling color slot for a child session's generated avatar (see + * [ai.kilocode.client.session.AgentAvatar]), recomputed from the model's current child spawn + * order on each lookup rather than cached, so a foreground task card and a promoted + * background-agent row read the same hue without any extra invalidation bookkeeping. Cheap: only + * called from task card `sync()`, never from icon paint or animation frames. + */ + private fun avatarColor(childSessionId: String): Int? = + ai.kilocode.client.session.AgentAvatarIdentity.palette(model.childSessions())[childSessionId] + // ------ private event handlers ------ private fun onTurnAdded(turn: ai.kilocode.client.session.model.Turn) { - val tv = TurnView(turn.id, openFile, style, openUrl, selection, openAttachment, resize, repo, ::hover, revert, fork, deleteQueued, onOpenSubagent).also { + val tv = TurnView(turn.id, openFile, style, openUrl, selection, openAttachment, resize, repo, ::hover, revert, fork, deleteQueued, onOpenSubagent, onPromoteBackgroundAgent, ::avatarColor).also { it.setDiffOpener(openDiff, sessionId) } turnViews[turn.id] = tv @@ -383,7 +396,7 @@ class SessionMessageListPanel( removeAll() for (turn in model.turns()) { - val tv = TurnView(turn.id, openFile, style, openUrl, selection, openAttachment, resize, repo, ::hover, revert, fork, deleteQueued, onOpenSubagent).also { + val tv = TurnView(turn.id, openFile, style, openUrl, selection, openAttachment, resize, repo, ::hover, revert, fork, deleteQueued, onOpenSubagent, onPromoteBackgroundAgent, ::avatarColor).also { it.setDiffOpener(openDiff, sessionId) } turnViews[turn.id] = tv diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/BackgroundAgentStrip.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/BackgroundAgentStrip.kt new file mode 100644 index 000000000000..de88a7adfa70 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/BackgroundAgentStrip.kt @@ -0,0 +1,457 @@ +package ai.kilocode.client.session.ui.header + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.AgentAvatar +import ai.kilocode.client.session.SpinnerIcon +import ai.kilocode.client.session.background.BackgroundAgent +import ai.kilocode.client.session.background.BackgroundAgentStatus +import ai.kilocode.client.session.ui.SessionSurface +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.session.views.SessionViewIcons +import ai.kilocode.client.ui.HoverIcon +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.HAlign +import ai.kilocode.client.ui.layout.Stack +import ai.kilocode.client.ui.layout.StackAxis +import ai.kilocode.client.ui.layout.VAlign +import ai.kilocode.client.ui.layout.align +import com.intellij.icons.AllIcons +import com.intellij.ui.components.JBLabel +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBUI +import java.awt.BorderLayout +import java.awt.Color +import java.awt.Component +import java.awt.Container +import java.awt.Cursor +import java.awt.Dimension +import java.awt.Graphics +import java.awt.Rectangle +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import javax.swing.Icon +import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.Scrollable +import javax.swing.SwingUtilities + +/** + * Background subagent status strip: a collapsible one-line summary of running/finished background + * agents, matching [TodoStrip]'s chrome (see [Strip]). Reachable actions: + * + * - Clicking a row opens that agent's read-only transcript via [onOpen]. + * - "Stop" / "Stop all" cancel one or every running agent via [onCancel] / [onCancelAll]. + * - "Dismiss" / "Clear finished" hide finished rows locally via [onDismiss] — this never deletes + * the child session or the backend job record. + * - "Open all" opens every visible agent's transcript. + * + * All rows are pure UI over [BackgroundAgent] — no RPC, no services; the owning [SessionHeaderPanel] + * feeds [update] from [ai.kilocode.client.session.model.SessionModel.backgroundAgents]. + */ +private const val MAX_VISIBLE_ROWS = 5 + +class BackgroundAgentStrip( + private val readonly: Boolean, + private val onOpen: (String, String) -> Unit, + private val onCancel: (String) -> Unit, + private val onCancelAll: (List) -> Unit, + private val onDismiss: (Set) -> Unit, + /** Sibling color slot for a row's generated avatar. See [AgentAvatar]. */ + private val avatarColor: (String) -> Int? = { null }, +) : Strip() { + + override val vertical = true + + private var agents: List = emptyList() + private var activeCount = 0 + private val rows = LinkedHashMap() + private val body = Body() + + private val stopAll = HoverIcon().apply { + icon = AllIcons.Actions.Suspend + isVisible = false + addActionListener { onCancelAll(agents.filter { it.status == BackgroundAgentStatus.RUNNING }.map { it.job }) } + } + private val clearFinished = HoverIcon().apply { + icon = AllIcons.Actions.Close + isVisible = false + toolTipText = KiloBundle.message("session.header.agents.clearFinished") + accessibleContext.accessibleName = toolTipText + addActionListener { + onDismiss(agents.filterNot { it.status == BackgroundAgentStatus.RUNNING }.map { it.job }.toSet()) + } + } + private val openAll = HoverIcon().apply { + icon = SessionViewIcons.openDiff + isVisible = false + toolTipText = KiloBundle.message("session.header.agents.openAll") + accessibleContext.accessibleName = toolTipText + addActionListener { agents.forEach { onOpen(it.session, title(it)) } } + } + + init { + summary.toolTipText = KiloBundle.message("session.header.agents.toggle") + summary.accessibleContext.accessibleName = KiloBundle.message("session.header.agents.toggle") + actions.next(stopAll).next(clearFinished).next(openAll) + } + + override fun createBody(): JComponent = body + + @RequiresEdt + fun update(agents: List) { + // SessionHeaderPanel re-syncs this strip on every HeaderUpdated, which streams as tokens and + // cost arrive, so bail out unless the agents actually changed — otherwise the retained strip + // would revalidate and repaint on every event in the streaming hot path. + if (agents == this.agents) return + this.agents = agents + val running = agents.count { it.status == BackgroundAgentStatus.RUNNING } + set(label, caption(agents)) + val aggregateIcon = statusIcon(state(agents)) + glyph.icon = aggregateIcon + glyph.isVisible = agents.isNotEmpty() && aggregateIcon != null + stopAll.text = KiloBundle.message("session.header.agents.stopAll", running) + stopAll.isVisible = !readonly && running > 0 + clearFinished.isVisible = !readonly && agents.any { it.status != BackgroundAgentStatus.RUNNING } + openAll.isVisible = agents.isNotEmpty() + body.sync(agents) + syncVisible(agents.isNotEmpty()) + // Auto-collapse once, on the transition into "nothing active" — not on every subsequent + // update while it stays at zero, or a manual re-expand to dismiss a finished row would be + // immediately undone by the next poll tick. + val active = agents.count { it.status == BackgroundAgentStatus.RUNNING || it.waiting } + if (active == 0 && activeCount > 0 && expanded()) collapse() + activeCount = active + refresh() + } + + @RequiresEdt + override fun applyStyle(style: SessionEditorStyle) { + super.applyStyle(style) + body.applyStyle(style) + } + + private fun title(agent: BackgroundAgent): String = + agent.title?.takeIf { it.isNotBlank() } ?: KiloBundle.message("session.header.agents.untitled") + + private fun caption(agents: List): String { + val running = agents.count { it.status == BackgroundAgentStatus.RUNNING } + val total = agents.size + return when { + agents.any { it.waiting } -> KiloBundle.message("session.header.agents.waiting") + total == 1 -> KiloBundle.message("session.header.agents.running.one") + running == 0 || running == total -> KiloBundle.message("session.header.agents.running.many", total) + else -> KiloBundle.message("session.header.agents.summary", running, total) + } + } + + private fun state(agents: List): BackgroundAgentStatus = when { + agents.any { it.status == BackgroundAgentStatus.RUNNING || it.waiting } -> BackgroundAgentStatus.RUNNING + agents.any { it.status == BackgroundAgentStatus.ERROR } -> BackgroundAgentStatus.ERROR + agents.any { it.status == BackgroundAgentStatus.CANCELLED } -> BackgroundAgentStatus.CANCELLED + else -> BackgroundAgentStatus.COMPLETED + } + + // Raised content surface painted with the editor background, matching TodoListPanel's block arc. + private inner class Body : Stack(StackAxis.VERTICAL, JBUI.scale(SessionUiStyle.View.Layout.GAP)), Scrollable { + // Ordered job IDs from the most recent `sync(agents)`, used to find the first five rows for + // the viewport height cap below. `Stack` keeps its own entry order (see the reorder comment + // in `sync`), which can briefly disagree with `rows.keys` while a reorder is in flight. + private var order = emptyList() + + init { + border = JBUI.Borders.empty(UiStyle.Gap.lg(), UiStyle.Gap.pad()) + } + + override fun paintComponent(g: Graphics) { + SessionSurface.fill(g, width, height) + super.paintComponent(g) + } + + override fun paintChildren(g: Graphics) { + SessionSurface.clipped(g, width, height) { super.paintChildren(it) } + } + + fun sync(agents: List) { + order = agents.map { it.job } + val ids = agents.map { it.job }.toSet() + for (stale in rows.keys.filter { it !in ids }) { + val row = rows.remove(stale) ?: continue + remove(row.panel) + } + for (agent in agents) { + val row = rows[agent.job] + if (row == null) { + val created = Row(::title, onOpen, ::onRowAction, readonly, avatarColor).also { it.applyStyle(style) } + created.update(agent) + rows[agent.job] = created + next(created.panel) + continue + } + row.update(agent) + } + // Rows that already exist are updated in place, so without this an agent that finishes + // would keep its old slot above still-active ones instead of following + // BackgroundAgents.order. Stack lays out from its own entries list rather than the AWT + // component array, and re-adding a child moves it to the end of that list, so restate the + // order by re-adding every row. setComponentZOrder would not work here: it reorders the + // component array that Stack's layout does not read. + val order = agents.map { it.job } + if (order != rows.keys.toList()) { + for (job in order) next(rows[job]?.panel ?: continue) + val sorted = order.mapNotNull { job -> rows[job]?.let { job to it } } + rows.clear() + rows.putAll(sorted) + } + revalidate() + repaint() + } + + fun applyStyle(style: SessionEditorStyle) { + for (row in rows.values) row.applyStyle(style) + } + + override fun getScrollableTracksViewportWidth() = true + + override fun getScrollableTracksViewportHeight() = false + + // Caps the expanded strip to five visible rows before it scrolls, per row's actual preferred + // height rather than a hardcoded pixel constant — row height varies with font size, IDE zoom, + // and Look and Feel metrics. + override fun getPreferredScrollableViewportSize(): Dimension { + val visible = order.asSequence().mapNotNull(rows::get).take(MAX_VISIBLE_ROWS).toList() + val ins = insets + val height = ins.top + ins.bottom + + visible.sumOf { it.panel.preferredSize.height } + + space * (visible.size - 1).coerceAtLeast(0) + return Dimension(preferredSize.width, height) + } + + override fun getScrollableUnitIncrement(visibleRect: Rectangle, orientation: Int, direction: Int) = + JBUI.scale(SessionUiStyle.SessionLayout.SCROLL_INCREMENT) + + override fun getScrollableBlockIncrement(visibleRect: Rectangle, orientation: Int, direction: Int) = + visibleRect.height + } + + private fun onRowAction(agent: BackgroundAgent) { + if (agent.status == BackgroundAgentStatus.RUNNING) onCancel(agent.job) else onDismiss(setOf(agent.job)) + } + + // ------ test accessors ------ + + internal fun stopAllButton(): HoverIcon = stopAll + internal fun clearFinishedButton(): HoverIcon = clearFinished + internal fun openAllButton(): HoverIcon = openAll + internal fun rowCount(): Int = rows.size + internal fun agentRowPanel(job: String): JComponent? = rows[job]?.panel + internal fun rowTitleText(job: String): String? = rows[job]?.title?.text + internal fun rowStatusText(job: String): String? = rows[job]?.status?.text + internal fun rowStatusIcon(job: String): Icon? = rows[job]?.statusGlyph?.icon + internal fun rowStatusGlyphVisible(job: String): Boolean = rows[job]?.statusGlyph?.isVisible == true + internal fun rowAvatarIcon(job: String): Icon? = rows[job]?.avatar?.icon + internal fun rowAvatarComponent(job: String): JComponent? = rows[job]?.avatar + internal fun rowNeedsInputVisible(job: String): Boolean = rows[job]?.needsInput?.isVisible == true + internal fun rowActionVisible(job: String): Boolean = rows[job]?.action?.isVisible == true + internal fun rowActionButton(job: String): HoverIcon? = rows[job]?.action + + private class Row( + private val titleFor: (BackgroundAgent) -> String, + private val onOpen: (String, String) -> Unit, + private val onAction: (BackgroundAgent) -> Unit, + private val readonly: Boolean, + private val avatarColor: (String) -> Int? = { null }, + ) { + private var agent = BackgroundAgent("", "", null, BackgroundAgentStatus.RUNNING) + // Recreated only when the child session id or its resolved sibling color changes; a status + // change just swaps between these two retained icons — see [AgentAvatar]. + private var avatarId = "" + private var avatarColorSlot: Int? = null + private var avatarStatic = AgentAvatar.static(avatarId, avatarColorSlot) + private var avatarRunning = AgentAvatar.running(avatarId, avatarColorSlot) + + /** Generated per-subagent identity, replacing the old shared running/terminal glyph here. */ + val avatar = JBLabel() + + /** + * Terminal-state glyph next to the status text: success/error only. Hidden while running or + * cancelled, reclaiming the slot; the running avatar already shows activity and cancellation + * is not an error condition. + */ + val statusGlyph = JBLabel() + val title = JBLabel() + val status = JBLabel() + val needsInput = JBLabel(KiloBundle.message("session.header.agents.needsInput")).apply { + foreground = UiStyle.Colors.warningLabelForeground() + isVisible = false + } + val action = HoverIcon().apply { isFocusable = false } + private val actionSlot = action.align(HAlign.RIGHT, VAlign.CENTER) + val panel = HoverPanel().apply { + border = JBUI.Borders.empty(UiStyle.Gap.xs(), UiStyle.Gap.sm()) + cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + // Icon + title + status are vertically centered and left-aligned; the action stays + // vertically centered on the trailing edge. Both use bounded-preferred `Align` wrappers + // rather than `BorderLayout`'s own stretch-to-height behavior, so a taller sibling never + // stretches the avatar or the action button out of their natural size. + add(avatar.align(HAlign.LEFT, VAlign.CENTER), BorderLayout.WEST) + add( + Stack.horizontal(UiStyle.Gap.sm()).apply { + next(title) + next(statusGlyph) + next(status) + next(needsInput) + }.align(HAlign.LEFT, VAlign.CENTER), + BorderLayout.CENTER, + ) + add(actionSlot, BorderLayout.EAST) + } + + /** Opens this agent's transcript in its own read-only editor tab. */ + private val click = object : MouseAdapter() { + override fun mouseClicked(event: MouseEvent) = onOpen(agent.session, titleFor(agent)) + } + + // Hover is tracked across the whole row subtree: Swing delivers mouseExited only to the + // deepest component, so a listener on the row alone would drop the fill as soon as the + // pointer reached the trailing action button. The children are built here and never + // replaced, so a one-time walk is enough — no ContainerListener needed. + private val pointer = object : MouseAdapter() { + override fun mouseEntered(event: MouseEvent) = hover(true) + + override fun mouseExited(event: MouseEvent) { + if (inside(event)) return + hover(false) + } + } + + init { + action.addActionListener { onAction(agent) } + watch(panel) + } + + /** + * Binds hover to the whole subtree, and the open-click to every element that does not already + * own a mouse listener. Swing delivers a click only to the innermost listener, so without the + * per-child binding the hover listener would swallow clicks on the icon and labels and only + * the row's padding would open the tab. The check runs before [pointer] is attached, so the + * trailing action button keeps its own action and never also opens the tab. + */ + private fun watch(component: Component) { + if (component.mouseListeners.isEmpty()) component.addMouseListener(click) + component.addMouseListener(pointer) + if (component is Container) component.components.forEach { watch(it) } + } + + /** + * Whether the pointer is still anywhere on this row, so moving onto a child keeps it lit. + * Bounds alone are not enough: an overlay painted above the session (the connection banner, + * the modal blocker) can own the pointer while sitting inside the row's rectangle, and Swing + * stops delivering to the row without it ever being left geometrically. Asking which + * component is topmost treats a covered row as left, so the fill clears instead of sticking. + */ + private fun inside(event: MouseEvent): Boolean { + val point = SwingUtilities.convertPoint(event.component, event.point, panel) + if (!panel.contains(point)) return false + val pane = SwingUtilities.getRootPane(panel)?.layeredPane ?: return true + val spot = SwingUtilities.convertPoint(event.component, event.point, pane) + val top = SwingUtilities.getDeepestComponentAt(pane, spot.x, spot.y) ?: return true + return SwingUtilities.isDescendingFrom(top, panel) + } + + private fun hover(value: Boolean) { + val before = panel.background + panel.isHovered = value + if (before.rgb == panel.background.rgb) return + panel.repaint() + } + + @RequiresEdt + fun update(next: BackgroundAgent) { + agent = next + val running = next.status == BackgroundAgentStatus.RUNNING + syncAvatar(next, running) + // No icon at all while running (the avatar's own pulse already shows activity) or for a + // cancelled agent (a user action, not something to flag) — `Stack` skips invisible + // children and their gaps, so hiding this reclaims the slot instead of leaving blank + // space reserved for it. + val terminalIcon = if (running) null else statusIcon(next.status) + statusGlyph.icon = terminalIcon + statusGlyph.isVisible = terminalIcon != null + title.text = titleFor(next) + status.text = statusText(next.status) + needsInput.isVisible = next.waiting + action.isVisible = !readonly + actionSlot.isVisible = !readonly + action.icon = if (running) AllIcons.Actions.Suspend else AllIcons.Actions.Close + val tip = KiloBundle.message(if (running) "session.header.agents.stop" else "session.header.agents.dismiss") + action.toolTipText = tip + action.accessibleContext.accessibleName = "$tip: ${titleFor(next)}" + } + + @RequiresEdt + private fun syncAvatar(next: BackgroundAgent, running: Boolean) { + val colorSlot = avatarColor(next.session) + if (next.session != avatarId || colorSlot != avatarColorSlot) { + avatarId = next.session + avatarColorSlot = colorSlot + avatarStatic = AgentAvatar.static(avatarId, colorSlot) + avatarRunning = AgentAvatar.running(avatarId, colorSlot) + } + avatar.icon = if (running) avatarRunning else avatarStatic + } + + @RequiresEdt + fun applyStyle(style: SessionEditorStyle) { + title.font = style.regularFont + title.foreground = style.editorForeground + status.font = style.smallFont + status.foreground = SessionUiStyle.Text.Secondary.foreground() + needsInput.font = style.smallFont + } + + /** + * Agent row with the same rounded hover fill a collapsed session-view card header uses, over + * the strip's raised surface instead of the backdrop. Stays non-opaque so the unhovered row + * lets the strip surface through and the hovered fill keeps its rounded corners. + */ + class HoverPanel : JPanel(BorderLayout(UiStyle.Gap.md(), 0)) { + var isHovered = false + + override fun isOpaque(): Boolean = false + + override fun getBackground(): Color = if (isHovered) { + SessionUiStyle.View.Surface.blockHoverBgColor() + } else { + SessionUiStyle.Colors.codeBlockBackground() + } + + override fun paintComponent(g: Graphics) { + super.paintComponent(g) + if (!isHovered) return + SessionSurface.fill(g, width, height, SessionUiStyle.View.Surface.blockHoverBgColor()) + } + } + } +} + +/** + * The aggregate (collapsed-summary) glyph for [status], and the per-row terminal glyph for a + * completed/error/cancelled row (`Row.update` substitutes `null` while running instead of calling + * this, since the avatar's own pulse already shows activity there). A cancelled agent deliberately + * has no glyph at all — cancelling is a user action, not a problem to flag — rather than a blank icon + * reserving the slot. + */ +private fun statusIcon(status: BackgroundAgentStatus): Icon? = when (status) { + BackgroundAgentStatus.RUNNING -> SpinnerIcon.icon + BackgroundAgentStatus.COMPLETED -> AllIcons.General.InspectionsOK + BackgroundAgentStatus.CANCELLED -> null + BackgroundAgentStatus.ERROR -> AllIcons.General.Error +} + +private fun statusText(status: BackgroundAgentStatus): String = when (status) { + BackgroundAgentStatus.RUNNING -> KiloBundle.message("session.header.agents.status.running") + BackgroundAgentStatus.COMPLETED -> KiloBundle.message("session.header.agents.status.completed") + BackgroundAgentStatus.CANCELLED -> KiloBundle.message("session.header.agents.status.cancelled") + BackgroundAgentStatus.ERROR -> KiloBundle.message("session.header.agents.status.error") +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt index 6473f1056395..6a41d199f83e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt @@ -8,11 +8,9 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.session.controller.SessionController import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.SessionViewIcons -import ai.kilocode.client.session.views.todo.TodoListPanel import ai.kilocode.client.ui.HoverIcon import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.Stack -import ai.kilocode.rpc.dto.TodoDto import ai.kilocode.rpc.dto.TokensDto import com.intellij.icons.AllIcons import com.intellij.ide.util.PropertiesComponent @@ -35,6 +33,7 @@ import java.awt.event.MouseWheelEvent import javax.swing.Box import javax.swing.BoxLayout import javax.swing.Icon +import javax.swing.JComponent import javax.swing.JPanel import javax.swing.JViewport import javax.swing.SwingUtilities @@ -46,6 +45,8 @@ class SessionHeaderPanel( /** Whether the shared agent board icon should show, re-derived on every [update]. */ private val boardVisible: () -> Boolean = { false }, private val onShowBoard: () -> Unit = {}, + /** Opens a background agent's read-only transcript. See [ai.kilocode.client.session.SessionUi.openSubagent]. */ + private val onOpenSubagent: (String, String) -> Unit = { _, _ -> }, ) : BorderLayoutPanel(), SessionEditorStyleTarget { companion object { @@ -69,9 +70,17 @@ class SessionHeaderPanel( } private val cost = JBLabel() private val context = JBLabel() - private val todos = JBLabel() - private val todoArrow = JBLabel(AllIcons.General.ArrowRight) - private val todoList = TodoListPanel() + private val agentStrip = BackgroundAgentStrip( + readonly = readonly, + onOpen = onOpenSubagent, + onCancel = controller::cancelBackgroundAgent, + onCancelAll = { jobs -> jobs.forEach(controller::cancelBackgroundAgent) }, + onDismiss = controller::dismissBackgroundAgents, + // Same source as the foreground task card's palette (all child sessions, not just the + // currently visible background rows), so a promoted agent keeps its hue. + avatarColor = { id -> ai.kilocode.client.session.AgentAvatarIdentity.palette(controller.model.childSessions())[id] }, + ) + private val todoStrip = TodoStrip() private val compact = HoverIcon().apply { icon = COMPRESS_ICON cursor = java.awt.Cursor.getPredefinedCursor(java.awt.Cursor.HAND_CURSOR) @@ -145,20 +154,6 @@ class SessionHeaderPanel( add(Box.createHorizontalStrut(UiStyle.Gap.sm())) add(cacheWrite) } - private val todoRow = JPanel(FlowLayout(FlowLayout.LEFT, UiStyle.Gap.sm(), 0)).apply { - isOpaque = false - border = JBUI.Borders.empty(UiStyle.Gap.sm(), 0, 0, 0) - cursor = java.awt.Cursor.getPredefinedCursor(java.awt.Cursor.HAND_CURSOR) - toolTipText = KiloBundle.message("session.header.todos.toggle") - accessibleContext.accessibleName = KiloBundle.message("session.header.todos.toggle") - add(todoArrow) - add(todos) - } - private val todoBox = JPanel().apply { - isOpaque = false - layout = BoxLayout(this, BoxLayout.Y_AXIS) - add(todoRow) - } private val body = JPanel().apply { isOpaque = false layout = BoxLayout(this, BoxLayout.Y_AXIS) @@ -171,7 +166,15 @@ class SessionHeaderPanel( add(viewport) add(tokens) add(bar) - add(todoBox) + } + // Always visible regardless of [isExpanded] — background agents and to-dos must not require + // expanding the metrics body to be seen (they cannot scroll away). See [Strip]. + private val strips = JPanel().apply { + isOpaque = false + layout = BoxLayout(this, BoxLayout.Y_AXIS) + border = JBUI.Borders.empty(0, UiStyle.Gap.xl(), UiStyle.Gap.md(), UiStyle.Gap.xl()) + add(agentStrip) + add(todoStrip) } private var style = SessionEditorStyle.current() private var costValue = "" @@ -184,6 +187,9 @@ class SessionHeaderPanel( top.add(title, BorderLayout.CENTER) top.add(right, BorderLayout.EAST) add(top, BorderLayout.NORTH) + // SOUTH, not stacked under `top`, so the expandable stats body (CENTER) opens directly + // below the title row and the strips stay pinned beneath it. + add(strips, BorderLayout.SOUTH) timeline.addMouseListener(object : MouseAdapter() { override fun mousePressed(event: MouseEvent) { press = event.point @@ -201,19 +207,11 @@ class SessionHeaderPanel( }) timeline.addMouseWheelListener { scroll(it) } viewport.addMouseWheelListener { scroll(it) } - val todoClick = object : MouseAdapter() { - override fun mouseClicked(event: MouseEvent) { - toggleTodos() - } - } - listOf(todoRow, todoArrow, todos).forEach { - it.cursor = java.awt.Cursor.getPredefinedCursor(java.awt.Cursor.HAND_CURSOR) - it.addMouseListener(todoClick) - } controller.model.addListener(parent) { event -> when (event) { is SessionModelEvent.HeaderUpdated -> update(event.header) + is SessionModelEvent.BackgroundAgentsUpdated -> agentStrip.update(event.agents) is SessionModelEvent.MessageAdded, is SessionModelEvent.MessageUpdated, @@ -270,7 +268,10 @@ class SessionHeaderPanel( set(context, contextText(header.context)) context.toolTipText = contextTip(header.context) setTokens(header.tokens) - syncTodos(header.todos.items) + todoStrip.update(header.todos.items) + // Driven from here as well as from BackgroundAgentsUpdated: the model suppresses a no-change + // event, so a session that never had a background agent would otherwise never sync the strip. + agentStrip.update(controller.model.backgroundAgents) compact.isVisible = !readonly compact.isEnabled = !readonly && header.canCompact @@ -293,8 +294,6 @@ class SessionHeaderPanel( top.border = JBUI.Borders.empty(UiStyle.Gap.md(), UiStyle.Gap.sm(), UiStyle.Gap.md(), UiStyle.Gap.sm()) right.background = bg tokens.background = bg - todoRow.background = bg - todoBox.background = bg body.background = bg viewport.background = bg title.font = style.boldFont @@ -304,10 +303,8 @@ class SessionHeaderPanel( cost.icon = null context.font = style.regularFont context.foreground = style.editorForeground - todos.font = style.smallFont - todos.foreground = style.editorForeground - todoArrow.foreground = style.editorForeground - todoList.applyStyle(style) + agentStrip.applyStyle(style) + todoStrip.applyStyle(style) tokenTitle.font = style.smallFont tokenTitle.foreground = style.editorForeground input.font = style.smallFont @@ -332,34 +329,41 @@ class SessionHeaderPanel( internal fun contextText(): String = context.text - internal fun foregrounds() = listOf(title, cost, context, todos, tokenTitle, input, output, cacheWrite, cacheRead) - .map { it.foreground } + internal fun foregrounds() = listOf(title, cost, context).map { it.foreground } + + todoStrip.labelForeground() + + listOf(tokenTitle, input, output, cacheWrite, cacheRead).map { it.foreground } internal fun tokenText(): String = listOf(tokenTitle, input, output, cacheWrite, cacheRead) .filter { it.isVisible } .joinToString(" ") { it.text } - internal fun tokenTip(): String = tokens.toolTipText + // ------ background agents strip test accessors ------ - internal fun inputTokenText(): String = input.text + internal fun agentStripPanel(): BackgroundAgentStrip = agentStrip - internal fun outputTokenText(): String = output.text + // ------ todo strip test accessors (delegate to TodoStrip; kept for existing tests) ------ - internal fun cacheReadText(): String = cacheRead.text + internal fun todoText(): String = todoStrip.labelText() - internal fun cacheWriteText(): String = cacheWrite.text + internal fun todoVisible(): Boolean = todoStrip.isVisible - internal fun todoText(): String = todos.text + internal fun todoListVisible(): Boolean = todoStrip.bodyAttached() - internal fun todoVisible() = todoRow.isVisible && todos.isVisible + internal fun todoRowPanel(): JComponent = todoStrip.rowPanel() - internal fun todoListVisible() = todoList.parent === todoBox + internal fun todoLabel(): JComponent = todoStrip.labelComponent() - internal fun todoRowPanel() = todoRow + internal fun todoListPanel(): TodoStrip = todoStrip - internal fun todoLabel() = todos + internal fun tokenTip(): String = tokens.toolTipText - internal fun todoListPanel() = todoList + internal fun inputTokenText(): String = input.text + + internal fun outputTokenText(): String = output.text + + internal fun cacheReadText(): String = cacheRead.text + + internal fun cacheWriteText(): String = cacheWrite.text internal fun compactButton() = compact @@ -455,41 +459,6 @@ class SessionHeaderPanel( cost.isVisible = costValue.isNotBlank() } - private fun syncTodos(items: List) { - val total = items.size - val done = items.count { it.status == "completed" } - set(todos, todo(done, total)) - todos.foreground = if (total > 0 && done == total) SessionUiStyle.Timeline.SUCCESS else style.editorForeground - todoArrow.isVisible = total > 0 - todoBox.isVisible = total > 0 - todoRow.isVisible = total > 0 - todoList.update(items) - if (total == 0) collapseTodos() - } - - private fun toggleTodos() { - if (!todoBox.isVisible) return - if (todoListVisible()) collapseTodos() else expandTodos() - refresh() - } - - private fun expandTodos(): Boolean { - if (todoListVisible()) return false - todoBox.add(todoList) - todoArrow.icon = AllIcons.General.ArrowDown - return true - } - - private fun collapseTodos(): Boolean { - if (!todoListVisible()) { - todoArrow.icon = AllIcons.General.ArrowRight - return false - } - todoBox.remove(todoList) - todoArrow.icon = AllIcons.General.ArrowRight - return true - } - private fun toggle() { val next = !isExpanded() syncExpanded(next) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/Strip.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/Strip.kt new file mode 100644 index 000000000000..177e3aa222cb --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/Strip.kt @@ -0,0 +1,201 @@ +package ai.kilocode.client.session.ui.header + +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.HAlign +import ai.kilocode.client.ui.layout.Stack +import ai.kilocode.client.ui.layout.VAlign +import ai.kilocode.client.ui.layout.align +import com.intellij.icons.AllIcons +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBScrollPane +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.components.BorderLayoutPanel +import java.awt.BorderLayout +import java.awt.Cursor +import java.awt.Dimension +import java.awt.FlowLayout +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import javax.swing.BoxLayout +import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.ScrollPaneConstants + +/** + * Shared chrome for a collapsible, one-line-when-collapsed status strip in the session header: an + * arrow, an optional leading [glyph], a summary [label], optional trailing [actions], and a + * lazily-created expanded body. [TodoStrip] and [BackgroundAgentStrip] both render an identical + * collapsed row and expand/collapse control, so the header shows one consistent chrome for either + * kind of strip — see `packages/kilo-vscode/docs/features/background-agent-visibility.md`'s "same + * slot as the to-do strip" precedent. + * + * Retained mode like the rest of the session UI: [expanded] is derived from containment (the body's + * parent), never a separate boolean, and [createBody] runs at most once per instance. + * + * Non-opaque throughout: strips sit inside [SessionHeaderPanel], whose own background is the single + * opaque surface for the header, per this plugin's session-background strategy. + */ +abstract class Strip : JPanel(), SessionEditorStyleTarget { + + protected val arrow = JBLabel(AllIcons.General.ArrowRight) + protected val glyph = JBLabel().apply { isVisible = false } + protected val label = JBLabel() + protected val actions: Stack = Stack.horizontal(UiStyle.Gap.sm()) + + /** Left cluster that toggles expand/collapse; [actions] deliberately does not. */ + protected val summary = JPanel(FlowLayout(FlowLayout.LEFT, UiStyle.Gap.sm(), 0)).apply { + isOpaque = false + add(arrow) + add(glyph) + add(label) + } + private val row = BorderLayoutPanel().apply { + isOpaque = false + add(summary, BorderLayout.WEST) + add(actions.align(HAlign.RIGHT, VAlign.CENTER), BorderLayout.EAST) + } + private var body: JComponent? = null + + /** Todo bodies remain horizontal; background-agent rows opt into width-tracking vertical scroll. */ + protected open val vertical = false + + /** Current style snapshot, kept for subclasses that recompute a color outside [applyStyle] (e.g. on [update]). */ + protected var style: SessionEditorStyle = SessionEditorStyle.current() + private set + + init { + isOpaque = false + layout = BoxLayout(this, BoxLayout.Y_AXIS) + add(row) + // A strip has nothing to show until its owner reports content, so it starts hidden and each + // subclass's update() turns it on via syncVisible. + isVisible = false + val click = object : MouseAdapter() { + override fun mouseClicked(event: MouseEvent) = toggle() + } + // `row` spans the full width, so the empty space beside the summary toggles too. The arrow, + // glyph, and label keep their own listeners so a click lands on them directly; Swing + // delivers a click only to the innermost listener, so this cannot toggle twice. Controls in + // [actions] own their listeners and are therefore never retargeted here. + listOf(row, summary, arrow, glyph, label).forEach { + it.cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + it.addMouseListener(click) + } + } + + /** Build the expanded body. Called at most once; the result is retained for the strip's lifetime. */ + protected abstract fun createBody(): JComponent + + /** Whether the body is currently attached — containment-derived, never a separate boolean. */ + fun expanded(): Boolean = body?.parent === this + + @RequiresEdt + protected fun toggle() { + if (expanded()) collapse() else expand() + refresh() + } + + @RequiresEdt + protected fun expand(): Boolean { + if (expanded()) return false + val content = body ?: Scroller(createBody(), vertical).also { body = it } + add(content) + arrow.icon = AllIcons.General.ArrowDown + return true + } + + @RequiresEdt + protected fun collapse(): Boolean { + val content = body + if (content == null || content.parent !== this) { + arrow.icon = AllIcons.General.ArrowRight + return false + } + remove(content) + arrow.icon = AllIcons.General.ArrowRight + return true + } + + /** Hide the whole strip and collapse its body. Every subclass calls this when it has nothing to show. */ + @RequiresEdt + protected fun syncVisible(visible: Boolean): Boolean { + if (!visible) collapse() + if (isVisible == visible) return false + isVisible = visible + return true + } + + protected fun refresh() { + revalidate() + repaint() + } + + @RequiresEdt + override fun applyStyle(style: SessionEditorStyle) { + this.style = style + label.font = style.smallFont + label.foreground = style.editorForeground + arrow.foreground = style.editorForeground + glyph.foreground = style.editorForeground + } + + // ------ test accessors ------ + + internal fun rowPanel(): JComponent = summary + internal fun rowComponent(): JComponent = row + internal fun labelComponent(): JComponent = label + internal fun arrowIcon() = arrow.icon + internal fun labelText(): String = label.text + internal fun labelForeground() = label.foreground + internal fun bodyAttached(): Boolean = expanded() + internal fun bodyComponent(): JComponent? = body +} + +/** + * Scroll host for a strip body. Todo strips use the horizontal default, so a long to-do line stays + * reachable instead of being clipped by the tool window width. Background-agent rows opt into + * [vertical] scrolling instead: the body tracks the viewport width (see + * `BackgroundAgentStrip.Body`), and the vertical bar caps how many rows show before scrolling so the + * trailing action button never leaves the viewport. + * + * In horizontal mode the overlapping scrollbar floats over the content rather than taking its own + * row, so an expanded strip's height never jumps when the bar appears. Preferred width is reported as + * zero — the wide content must not widen the header, and the strip's `BoxLayout.Y_AXIS` parent + * stretches the pane to the available width via the unbounded maximum. This is the same sizing shape + * `MdViewHybrid.sizeCodePane` uses for scrollable code blocks. + * + * In vertical mode the scrollbar takes its own column (not overlapping, so it cannot cover the action + * button) and height is left to the viewport's actual preferred/maximum size instead of being pinned. + */ +private class Scroller(view: JComponent, private val vertical: Boolean) : JBScrollPane(view) { + init { + border = JBUI.Borders.empty() + viewportBorder = JBUI.Borders.empty() + isOpaque = false + viewport.isOpaque = false + horizontalScrollBarPolicy = if (vertical) { + ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + } else { + ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED + } + verticalScrollBarPolicy = if (vertical) { + ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED + } else { + ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER + } + setOverlappingScrollBar(!vertical) + if (!vertical) verticalScrollBar.preferredSize = JBUI.emptySize() + } + + // Width is always supplied by the strip's parent, never by an unclipped title inside the + // viewport. In vertical mode `BackgroundAgentStrip.Body` tracks that viewport width; its + // scrollable preferred size only decides the capped five-row height. + override fun getPreferredSize() = Dimension(0, super.getPreferredSize().height) + + override fun getMinimumSize() = Dimension(0, preferredSize.height) + + override fun getMaximumSize() = Dimension(Int.MAX_VALUE, preferredSize.height) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/TodoStrip.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/TodoStrip.kt new file mode 100644 index 000000000000..6f63e6368af5 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/TodoStrip.kt @@ -0,0 +1,60 @@ +package ai.kilocode.client.session.ui.header + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.session.views.todo.TodoListPanel +import ai.kilocode.rpc.dto.TodoDto +import com.intellij.util.concurrency.annotations.RequiresEdt +import javax.swing.JComponent + +/** + * Collapsible to-do strip: "N/M todos complete" with a disclosure arrow, expanding into the full + * [TodoListPanel]. Extracted from [SessionHeaderPanel] so both strips share identical chrome — see + * [Strip]. + */ +class TodoStrip : Strip() { + + private val list = TodoListPanel() + private var items: List = emptyList() + + init { + summary.toolTipText = KiloBundle.message("session.header.todos.toggle") + summary.accessibleContext.accessibleName = KiloBundle.message("session.header.todos.toggle") + } + + override fun createBody(): JComponent = list + + @RequiresEdt + fun update(items: List) { + this.items = items + val done = items.count { it.status == "completed" } + set(label, todo(done, items.size)) + syncColor() + list.update(items) + syncVisible(items.isNotEmpty()) + refresh() + } + + @RequiresEdt + override fun applyStyle(style: SessionEditorStyle) { + super.applyStyle(style) + syncColor() + list.applyStyle(style) + } + + private fun syncColor() { + val done = items.count { it.status == "completed" } + label.foreground = if (items.isNotEmpty() && done == items.size) { + SessionUiStyle.Timeline.SUCCESS + } else { + style.editorForeground + } + } + + // ------ test accessors ------ + + internal fun rowCount() = list.rowCount() + internal fun rowText(index: Int) = list.rowText(index) + internal fun rowChecked(index: Int) = list.rowChecked(index) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt index c5f415bab32d..0960dc8dcce5 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt @@ -187,6 +187,17 @@ object SessionUiStyle { fun headerHoverBgColor(): Color = JBColor.lazy { UiStyle.Colors.blend(Colors.sessionBackground(), Outline.hoverColor(), HOVER_FILL_ALPHA) } + + /** + * The same hover recipe as [headerHoverBgColor] for rows that sit on a raised block + * surface (the to-do list, background-agent rows) rather than on the backdrop. Blending + * off [Colors.codeBlockBackground] keeps the tint reading as a highlight of the row's own + * base; reusing the backdrop-based [headerHoverBgColor] there would punch a + * backdrop-coloured patch through the raised surface. + */ + fun blockHoverBgColor(): Color = JBColor.lazy { + UiStyle.Colors.blend(Colors.codeBlockBackground(), Outline.hoverColor(), HOVER_FILL_ALPHA) + } } object Outline { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/BackgroundPromote.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/BackgroundPromote.kt new file mode 100644 index 000000000000..ad41a06cba3b --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/BackgroundPromote.kt @@ -0,0 +1,20 @@ +package ai.kilocode.client.session.views + +/** + * "Continue in background" wiring for a task card, pairing the action with a live availability check. + * + * [available] is re-read every time a card syncs rather than captured once, because the CLI's + * background-subagent capability is only known after the connection load completes and can change on + * reconnect, while task cards are created as parts stream in. Binding the gate to the action keeps a + * call site from wiring one without the other. + */ +class BackgroundPromote( + private val available: () -> Boolean, + private val promote: (String) -> Unit, +) { + /** Whether the connected CLI allows promoting a foreground subagent to the background. */ + fun available(): Boolean = available.invoke() + + /** Promotes the subagent owning child session [session]. */ + fun promote(session: String) = promote.invoke(session) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt index 2f47ca4b2a3b..a8ed1ac7b3d9 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt @@ -71,6 +71,9 @@ class MessageView( // Forks the session at this message. Null on surfaces that cannot fork (sidebar, read-only tabs). private val fork: ((String) -> Unit)? = null, private val onOpenSubagent: ((String, String) -> Unit)? = null, + private val onPromoteBackgroundAgent: BackgroundPromote? = null, + /** Sibling color slot for a child session's generated avatar. See [ai.kilocode.client.session.AgentAvatar]. */ + private val avatarColor: (String) -> Int? = { null }, ) : ai.kilocode.client.session.ui.SessionLayoutPanel( SessionUiStyle.SessionLayout.GAP, ), Disposable, SessionEditorStyleTarget, SessionView { @@ -411,9 +414,17 @@ class MessageView( } private fun view(content: Content) = if (msg.info.role == SessionUiStyle.View.Message.USER_ROLE) { - ViewFactory.createUser(content, openFile, openUrl, selection, repo, promptMentions(msg), { openAttachment(msg.info.id, it) }, openDiff, sessionId, onOpenSubagent) + ViewFactory.createUser( + content, openFile, openUrl, selection, repo, promptMentions(msg), + { openAttachment(msg.info.id, it) }, openDiff, sessionId, + onOpenSubagent = onOpenSubagent, avatarColor = avatarColor, onPromoteBackgroundAgent = onPromoteBackgroundAgent, + ) } else { - ViewFactory.create(content, openFile, openUrl, selection, repo, { openAttachment(msg.info.id, it) }, openDiff, sessionId, onOpenSubagent) + ViewFactory.create( + content, openFile, openUrl, selection, repo, + { openAttachment(msg.info.id, it) }, openDiff, sessionId, + onOpenSubagent = onOpenSubagent, avatarColor = avatarColor, onPromoteBackgroundAgent = onPromoteBackgroundAgent, + ) } private fun syncPromptMentions() { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt index c7c1a6e3006b..65741ef08d95 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt @@ -42,6 +42,9 @@ class TurnView( private val fork: ((String) -> Unit)? = null, private val deleteQueued: ((String) -> Unit)? = null, private val onOpenSubagent: ((String, String) -> Unit)? = null, + private val onPromoteBackgroundAgent: BackgroundPromote? = null, + /** Sibling color slot for a child session's generated avatar. See [ai.kilocode.client.session.AgentAvatar]. */ + private val avatarColor: (String) -> Int? = { null }, ) : SessionLayoutPanel(SessionUiStyle.SessionLayout.GAP), Disposable, SessionEditorStyleTarget, SessionView { private val messages = LinkedHashMap() @@ -79,7 +82,10 @@ class TurnView( /** Add a new [MessageView] for [msg] at the end of this turn. */ fun addMessage(msg: Message): MessageView { - val view = MessageView(msg, openFile, style, openUrl, selection, openAttachment, resize, repo, hover, revert, fork, onOpenSubagent).also { + val view = MessageView( + msg, openFile, style, openUrl, selection, openAttachment, resize, repo, hover, revert, fork, + onOpenSubagent, onPromoteBackgroundAgent, avatarColor, + ).also { it.setDiffOpener(openDiff, sessionId) } messages[msg.info.id] = view diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt index a88f72f7cba1..f222521b8afe 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt @@ -54,6 +54,12 @@ object ViewFactory { openDiff: SessionDiffOpener = { _, _, _ -> }, sessionId: String? = null, onOpenSubagent: ((String, String) -> Unit)? = null, + // avatarColor sits before onPromoteBackgroundAgent (not after) so this overload's last + // parameter stays a non-function type: if a lambda type were last here, a trailing-lambda + // call meant for the exact-arity `create(content, openFile, openUrl)` overload above would + // also become applicable to this one, making every such call site ambiguous. + avatarColor: (String) -> Int? = { null }, + onPromoteBackgroundAgent: BackgroundPromote? = null, ): PartView = when (content) { is Text -> TextView(content, openFile = openFile, openUrl = openUrl, selection = selection) is Reasoning -> ReasoningView(content, openFile = openFile, openUrl = openUrl, selection = selection) @@ -67,7 +73,13 @@ object ViewFactory { SearchToolView.canRender(content) -> SearchToolView(content, selection = selection, repo = repo) ReadToolView.canRender(content) -> ReadToolView(content, openFile, selection = selection) EditToolView.canRender(content) -> EditToolView(content, openFile, selection, openDiff, sessionId) - TaskToolView.canRender(content) -> TaskToolView(content, selection = selection, onOpenSubagent = onOpenSubagent) + TaskToolView.canRender(content) -> TaskToolView( + content, + selection = selection, + onOpenSubagent = onOpenSubagent, + onPromoteBackgroundAgent = onPromoteBackgroundAgent, + avatarColor = avatarColor, + ) BoardToolView.canRender(content) -> BoardToolView(content, selection = selection) else -> ToolView(content, selection = selection) } @@ -98,9 +110,15 @@ object ViewFactory { openDiff: SessionDiffOpener = { _, _, _ -> }, sessionId: String? = null, onOpenSubagent: ((String, String) -> Unit)? = null, + // See the matching comment on `create`: keep this last parameter a non-function type. + avatarColor: (String) -> Int? = { null }, + onPromoteBackgroundAgent: BackgroundPromote? = null, ): PartView = when (content) { is Text -> PromptView(content, openFile = openFile, openAttachment = openAttachment, openUrl = openUrl, selection = selection, mentions = mentions) - else -> create(content, openFile, openUrl, selection, repo, openAttachment, openDiff, sessionId, onOpenSubagent) + else -> create( + content, openFile, openUrl, selection, repo, openAttachment, openDiff, sessionId, + onOpenSubagent = onOpenSubagent, avatarColor = avatarColor, onPromoteBackgroundAgent = onPromoteBackgroundAgent, + ) } /** diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/TaskToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/TaskToolView.kt index 2f86568b8a26..3e035c21b9a3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/TaskToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/TaskToolView.kt @@ -1,6 +1,7 @@ package ai.kilocode.client.session.views.tool import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.AgentAvatar import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState @@ -11,9 +12,11 @@ import ai.kilocode.client.session.ui.selection.SessionCopyTarget import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.session.views.BackgroundPromote import ai.kilocode.client.session.views.SessionViewIcons import ai.kilocode.client.session.views.base.AbstractSessionPartView import ai.kilocode.client.session.views.base.HeaderOpenAction +import ai.kilocode.client.ui.HoverIcon import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.Stack import ai.kilocode.client.ui.layout.StackAxis @@ -21,6 +24,7 @@ import com.intellij.openapi.Disposable import com.intellij.openapi.actionSystem.DataSink import com.intellij.openapi.actionSystem.UiDataProvider import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.IconLoader import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBScrollPane import com.intellij.util.concurrency.annotations.RequiresEdt @@ -40,6 +44,9 @@ class TaskToolView( tool: Tool, private val selection: SessionSelection? = null, private val onOpenSubagent: ((String, String) -> Unit)? = null, + private val onPromoteBackgroundAgent: BackgroundPromote? = null, + /** Sibling color slot for the task's child session's generated avatar. See [AgentAvatar]. */ + private val avatarColor: (String) -> Int? = { null }, private val parts: ToolParts = toolParts(tool), private val footer: ToolApprovalFooter = ToolApprovalFooter(), ) : AbstractSessionPartView(parts.header, { TaskBody(parts.glyph).scroll }, { footer }), UiDataProvider, ApprovalReasonTarget, SessionCopyTarget { @@ -49,6 +56,12 @@ class TaskToolView( private var item = tool private var style = SessionEditorStyle.current() private val rows = LinkedHashMap() + // Recreated only when the child session id or its resolved sibling color changes; a state-only + // change (pending/running/completed/error) just swaps between these two retained icons. + private var avatarId = "" + private var avatarColorSlot: Int? = null + private var avatarStatic = AgentAvatar.static(avatarId, avatarColorSlot) + private var avatarRunning = AgentAvatar.running(avatarId, avatarColorSlot) private var following = false private var collapsed = false private var popup: HeaderPopupBody? = null @@ -59,12 +72,22 @@ class TaskToolView( ::openSubagent, ) + // A plain inline button, not a hover overlay: SessionCopyTarget only reserves the hover-copy + // slot for one control (open.anchor), and this action only applies to a running foreground task. + private val promoteButton = HoverIcon().apply { + icon = CONTINUE_IN_BACKGROUND_ICON + isFocusable = false + isVisible = false + toolTipText = KiloBundle.message("session.part.tool.continueInBackground") + addActionListener { promoteBackgroundAgent() } + } + init { // Mirror the edit/patch cards: move the summary and the open action into the non-fit left // group so the anchor always reserves its width right after the text. Left in the fill slot // (a fitHorizontal stack), a long summary would clip the trailing anchor to zero width and // the hover open control could fail to appear. - parts.header.left(parts.sub, open.anchor) + parts.header.left(parts.sub, promoteButton, open.anchor) applyStyle(style) sync() if (item.childTools.isNotEmpty()) expand() @@ -153,23 +176,58 @@ class TaskToolView( var changed = false changed = syncExpandable(item.childTools.isNotEmpty()) || changed changed = setVisible(parts.state, item.childTools.isEmpty()) || changed - changed = setIcon(parts.glyph, icon(item)) || changed - changed = setForeground(parts.glyph, color(item)) || changed + changed = syncAvatar() || changed changed = setText(parts.title, agentTitle(item)) || changed changed = setText(parts.sub, summary(item)) || changed changed = setForeground(parts.title, titleColor(item)) || changed changed = setText(parts.state, stateText(item)) || changed changed = setForeground(parts.state, color(item)) || changed + changed = setVisible(promoteButton, canPromote(item)) || changed changed = footer.update(item, approvalReasonsVisible()) || changed return changed } + /** + * Generated per-subagent avatar (see [AgentAvatar]): the neutral unknown glyph before + * `childSessionId` is known, then a stable shape/hue keyed by that id. Running/pending shows the + * pulsing variant; completed/error settle on the same static glyph. Never tints through + * `parts.glyph.foreground` — the identity's color is baked into the icon itself. + */ + @RequiresEdt + private fun syncAvatar(): Boolean { + val id = item.childSessionId ?: "" + val colorSlot = if (id.isEmpty()) null else avatarColor(id) + if (id != avatarId || colorSlot != avatarColorSlot) { + avatarId = id + avatarColorSlot = colorSlot + avatarStatic = AgentAvatar.static(id, colorSlot) + avatarRunning = AgentAvatar.running(id, colorSlot) + } + val running = item.state == ToolExecState.PENDING || item.state == ToolExecState.RUNNING + return setIcon(parts.glyph, if (running) avatarRunning else avatarStatic) + } + + /** + * Visible only when the CLI allows background subagents and this is a running foreground task + * with a child session that is not already in the background. Without the capability check the + * button would be offered on a CLI whose kill switch is off, where promoting always fails. + */ + private fun canPromote(tool: Tool): Boolean = onPromoteBackgroundAgent?.available() == true && + tool.childSessionId != null && + tool.state == ToolExecState.RUNNING && + tool.metadata["background"] != "true" + private fun openSubagent() { val id = item.childSessionId ?: return val title = listOf(agentTitle(item), summary(item)).filter { it.isNotBlank() }.joinToString(" - ") onOpenSubagent?.invoke(id, title) } + private fun promoteBackgroundAgent() { + val id = item.childSessionId ?: return + onPromoteBackgroundAgent?.promote(id) + } + private fun syncRows(): Boolean { if (!hasBody()) return false val body = taskBody() @@ -359,6 +417,7 @@ class TaskToolView( companion object { private const val POPUP_MIN_CHARS = 60 + private val CONTINUE_IN_BACKGROUND_ICON = IconLoader.getIcon("/icons/arrow-down-to-line.svg", TaskToolView::class.java) fun canRender(content: Tool): Boolean = content.name == "task" } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/AgentBehaviorSettingsUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/AgentBehaviorSettingsUi.kt index be9e1c6f2cc5..dfed2f112e77 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/AgentBehaviorSettingsUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/AgentBehaviorSettingsUi.kt @@ -124,7 +124,7 @@ internal class AgentBehaviorContent( }.apply { border = JBUI.Borders.emptyBottom(UiStyle.Gap.sm()) }) } // Own group below the sub-page links, matching AdvancedSettingsUi's separator-per-group idiom. - rows.row(TitledSeparator(KiloBundle.message("settings.agentBehavior.swarm.title"))) + rows.row(TitledSeparator(KiloBundle.message("settings.agentBehavior.extended.title"))) rows.row(SettingsRow( KiloBundle.message("settings.agentBehavior.swarm.enabled"), KiloBundle.message("settings.agentBehavior.swarm.description"), diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt index 629ac3f63ffc..0850658d378d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt @@ -351,32 +351,23 @@ object UiStyle { fun errorLabelForeground(): Color = JBColor.namedColor("Label.errorForeground", UIUtil.getErrorForeground()) /** - * Per-participant avatar fills for the Kilo Swarm board, keyed by a participant's position in - * the board's order. Mirrors `AgentAvatarPalette` in `packages/kilo-ui` so the same subagent - * reads the same colour across clients, which is why the fallbacks are exact values; each is - * exposed under a semantic key so a theme can still override it. + * Per-subagent avatar hues for the generated dot-glyph identity (see + * `ai.kilocode.client.session.AgentAvatar`), keyed by a sibling's assigned color slot. + * Mirrors the eight hues in `packages/kilo-ui/src/components/agent-avatar.css` so the same + * subagent reads with a similar hue across clients; each is exposed under a semantic key so a + * theme can still override it. */ - fun swarmAvatar(index: Int): Color = swarmAvatars[index.mod(swarmAvatars.size)]() - - /** Neutral fill for `main` and for any participant outside the known order. */ - fun swarmAvatarMain(): Color = JBColor.namedColor( - "Kilo.Swarm.avatarMainBackground", - JBColor(0x6B7280, 0x9CA3AF), - ) - - private val swarmAvatars: List<() -> Color> = listOf( - { JBColor.namedColor("Kilo.Swarm.avatarBackground1", JBColor(0x3574F0, 0x548AF7)) }, - { JBColor.namedColor("Kilo.Swarm.avatarBackground2", JBColor(0x1A9E77, 0x2FBE96)) }, - { JBColor.namedColor("Kilo.Swarm.avatarBackground3", JBColor(0xB5651D, 0xD4813A)) }, - { JBColor.namedColor("Kilo.Swarm.avatarBackground4", JBColor(0x8957E5, 0xA679F0)) }, - { JBColor.namedColor("Kilo.Swarm.avatarBackground5", JBColor(0xC74F4F, 0xE06666)) }, - { JBColor.namedColor("Kilo.Swarm.avatarBackground6", JBColor(0x2E8FB8, 0x4CB4DE)) }, - ) - - /** Initial drawn on top of a swarm avatar fill; the fills are saturated in both themes. */ - fun swarmAvatarForeground(): Color = JBColor.namedColor( - "Kilo.Swarm.avatarForeground", - JBColor(Color.WHITE, Color.WHITE), + fun avatarHue(index: Int): Color = avatarHues[index.mod(avatarHues.size)]() + + private val avatarHues: List<() -> Color> = listOf( + { JBColor.namedColor("Kilo.Agent.avatarHueBlue", JBColor(0x3574F0, 0x548AF7)) }, + { JBColor.namedColor("Kilo.Agent.avatarHueOrange", JBColor(0xE66D17, 0xC77D55)) }, + { JBColor.namedColor("Kilo.Agent.avatarHueGreen", JBColor(0x208A3C, 0x57965C)) }, + { JBColor.namedColor("Kilo.Agent.avatarHuePurple", JBColor(0x834DF0, 0xB589EC)) }, + { JBColor.namedColor("Kilo.Agent.avatarHueYellow", JBColor(0xC27D04, 0xD6AE58)) }, + { JBColor.namedColor("Kilo.Agent.avatarHueTeal", JBColor(0x1A9E77, 0x2FBE96)) }, + { JBColor.namedColor("Kilo.Agent.avatarHueRed", JBColor(0xDB3B4B, 0xDB5C5C)) }, + { JBColor.namedColor("Kilo.Agent.avatarHuePink", JBColor(0xC74F4F, 0xE06666)) }, ) fun addedForeground(): Color = JBColor.namedColor( diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloFileIconProvider.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloFileIconProvider.kt new file mode 100644 index 000000000000..50e911ead952 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloFileIconProvider.kt @@ -0,0 +1,31 @@ +package ai.kilocode.client.vfs + +import com.intellij.ide.FileIconProvider +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import javax.swing.Icon + +/** + * Supplies each [KiloVirtualFileKind]'s [KiloVirtualFileKind.icon] to IntelliJ's file-icon pipeline + * (project view rows, Search Everywhere, and editor tabs). + * + * [KiloVirtualFile] only overrides [KiloVirtualFile.getFileType], not an icon — `VirtualFile` has no + * `getIcon()` to override. Icon resolution instead goes through the public [FileIconProvider] + * extension point (queried before the file-type fallback), so without this provider every Kilo tab + * falls back to `FileTypes.UNKNOWN`'s generic icon regardless of what a kind's `icon()` returns. + * + * Matches via [kiloPath] rather than `is KiloVirtualFile`, so a split-mode protocol/proxy file for + * the same path still resolves, matching [KiloFileEditorProvider]'s own lookup. + * + * Must stay fast and side-effect-free: IntelliJ may call this from a background read action, in dumb + * mode, before the editor for the file exists at all. It only derives the icon from the file's own + * path/params, never from `SessionModel` or other EDT-only session state — see + * [ai.kilocode.client.session.subagent.SubagentSessionEditorKind.icon]. + */ +class KiloFileIconProvider : FileIconProvider { + override fun getIcon(file: VirtualFile, flags: Int, project: Project?): Icon? { + val path = kiloPath(file) ?: return null + return service().get(path.kind)?.icon(path.params) + } +} 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 971141424c24..1a22b169f527 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 @@ -29,6 +29,7 @@ + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index 85c481d0d951..ee0932fedecb 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -218,6 +218,7 @@ diff.overflow.open=Open in a diff tab session.part.tool.copy=Copy session.part.tool.openDiff=Open in Diff Viewer session.part.tool.openSubagent=Open sub-agent in editor +session.part.tool.continueInBackground=Continue in background session.part.tool.error=Error session.part.tool.agent={0} Agent session.part.tool.approval.auto=Auto-approved @@ -287,6 +288,24 @@ session.header.cost.tooltip={0} spent in this session session.header.todos.progress={0}/{1} todos complete session.header.todos.done=All {0} todos complete session.header.todos.toggle=Toggle to-dos +session.header.agents.toggle=Toggle background agents +session.header.agents.running.one=1 agent +session.header.agents.running.many={0} agents +session.header.agents.summary={0} of {1} agents running +session.header.agents.waiting=Background agent needs input +session.header.agents.needsInput=Needs input +session.header.agents.untitled=Background Agent +session.header.agents.openAll=Open all +session.header.agents.stop=Stop +session.header.agents.stopAll=Stop all ({0}) +session.header.agents.dismiss=Dismiss +session.header.agents.clearFinished=Clear finished +session.header.agents.status.running=Running +session.header.agents.status.completed=Done +session.header.agents.status.cancelled=Cancelled +session.header.agents.status.error=Error +session.header.agents.disabledTitle=Background agents disabled +session.header.agents.disabledMessage=This CLI has background subagents disabled, so the task will keep running in the foreground. session.header.context.tooltip.percent={0} tokens ({1}% of context) session.header.context.tooltip.tokens={0} tokens session.header.context.used={0} / {1} tokens used @@ -686,7 +705,7 @@ settings.providers.displayName=Providers settings.marketplace.displayName=Marketplace settings.agentBehavior.displayName=Agent Behavior settings.agentBehavior.description=Configure agents, MCP servers, rules, workflows, and skills. -settings.agentBehavior.swarm.title=Kilo Swarm +settings.agentBehavior.extended.title=Extended agents settings.agentBehavior.swarm.enabled=Enable Kilo Swarm settings.agentBehavior.swarm.description=Share a board between a main session and its task subagents, including nested subagents. Use it for parallel solution attempts or complementary work, not every task. settings.agentBehavior.empty=No items found. diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/AgentAvatarTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/AgentAvatarTest.kt new file mode 100644 index 000000000000..d9da5ef4984b --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/AgentAvatarTest.kt @@ -0,0 +1,135 @@ +package ai.kilocode.client.session + +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.AnimatedIcon +import com.intellij.util.ui.JBUI +import java.awt.image.BufferedImage +import javax.swing.Icon + +/** + * Parity fixtures generated by running the actual web implementation + * (`packages/kilo-ui/src/components/agent-avatar-identity.ts`) against representative ids, so this + * port is checked against real output rather than against a re-derivation of the same algorithm. + * Regenerate with `bun run