diff --git a/packages/kilo-jetbrains/backend/build.gradle.kts b/packages/kilo-jetbrains/backend/build.gradle.kts index b4cae1beac5..5924d04a5f9 100644 --- a/packages/kilo-jetbrains/backend/build.gradle.kts +++ b/packages/kilo-jetbrains/backend/build.gradle.kts @@ -43,6 +43,7 @@ openApiGenerate { "anyOf<>" to "kotlin.Any", "number" to "kotlin.Double", "decimal" to "kotlin.Double", + "integer" to "kotlin.Long", )) openapiNormalizer.set(mapOf( "SIMPLIFY_ANYOF_STRING_AND_ENUM_STRING" to "true", 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 b5e014623e8..dd649bfd1ec 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 @@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.serialization.json.JsonPrimitive +import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request @@ -156,6 +157,36 @@ class KiloBackendSessionManager( directories.remove(id) } + /** + * Rename a session by sending `PATCH /session/{id}?directory={dir}` with `{"title":"..."}`. + * + * Uses raw HTTP because the generated Kotlin client is build-time only and + * this repo already uses raw HTTP for session create and cloud operations. + */ + fun rename(id: String, dir: String, title: String): SessionDto { + val h = http ?: throw IllegalStateException("Session manager not started") + val url = base ?: throw IllegalStateException("Session manager not started") + val json = """{"title":"${escape(title)}"}""" + val patch = url.toHttpUrl().newBuilder() + .addPathSegment("session") + .addPathSegment(id) + .addQueryParameter("directory", dir) + .build() + val request = Request.Builder() + .url(patch) + .method("PATCH", json.toRequestBody("application/json".toMediaType())) + .build() + + h.newCall(request).execute().use { response -> + val raw = response.body?.string() + if (!response.isSuccessful) { + log.warn("Session rename failed: HTTP ${response.code}, body=$raw") + throw RuntimeException("Session rename failed: HTTP ${response.code} — $raw") + } + return KiloCliDataParser.parseSession(raw!!) + } + } + fun cloudSessions(dir: String, cursor: String?, limit: Int, gitUrl: String?): CloudSessionListDto { val h = http ?: throw IllegalStateException("Session manager not started") val url = base ?: throw IllegalStateException("Session manager not started") @@ -238,9 +269,9 @@ class KiloBackendSessionManager( ), summary = s.summary?.let { SessionSummaryDto( - additions = it.additions.toInt(), - deletions = it.deletions.toInt(), - files = it.files.toInt(), + additions = it.additions.safeInt(), + deletions = it.deletions.safeInt(), + files = it.files.safeInt(), ) }, ) @@ -259,9 +290,9 @@ class KiloBackendSessionManager( ), summary = s.summary?.let { SessionSummaryDto( - additions = it.additions.toInt(), - deletions = it.deletions.toInt(), - files = it.files.toInt(), + additions = it.additions.safeInt(), + deletions = it.deletions.safeInt(), + files = it.files.safeInt(), ) }, ) @@ -269,8 +300,8 @@ class KiloBackendSessionManager( private fun statusDto(s: SessionStatus) = SessionStatusDto( type = s.type.value, message = s.message.ifBlank { null }, - attempt = s.attempt.toInt(), - next = s.next.toLong(), + attempt = s.attempt.safeInt(), + next = s.next, requestID = s.requestID.ifBlank { null }, ) @@ -288,4 +319,6 @@ class KiloBackendSessionManager( } } } + + private fun Long.safeInt() = coerceIn(Int.MIN_VALUE.toLong(), Int.MAX_VALUE.toLong()).toInt() } 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 3371a389004..9e097feae5c 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 @@ -200,8 +200,8 @@ object KiloCliDataParser { val file = d.str("file") ?: return@mapNotNull null DiffFileDto( file = file, - additions = d.long("additions")?.toInt() ?: 0, - deletions = d.long("deletions")?.toInt() ?: 0, + additions = d.long("additions")?.safeInt() ?: 0, + deletions = d.long("deletions")?.safeInt() ?: 0, patch = d.str("patch"), ) } ?: emptyList() @@ -537,9 +537,9 @@ object KiloCliDataParser { ), summary = summary?.let { SessionSummaryDto( - additions = it.long("additions")?.toInt() ?: 0, - deletions = it.long("deletions")?.toInt() ?: 0, - files = it.long("files")?.toInt() ?: 0, + additions = it.long("additions")?.safeInt() ?: 0, + deletions = it.long("deletions")?.safeInt() ?: 0, + files = it.long("files")?.safeInt() ?: 0, ) }, ) @@ -564,7 +564,7 @@ object KiloCliDataParser { return SessionStatusDto( type = type, message = st.str("message"), - attempt = st.long("attempt")?.toInt(), + attempt = st.long("attempt")?.safeInt(), next = st.long("next"), requestID = st.str("requestID"), ) @@ -679,6 +679,8 @@ private fun JsonObject.num(key: String): Double? = private fun JsonObject.long(key: String): Long? = this[key]?.jsonPrimitive?.longOrNull +private fun Long.safeInt() = coerceIn(Int.MIN_VALUE.toLong(), Int.MAX_VALUE.toLong()).toInt() + private fun JsonObject?.map(key: String): Map { val obj = this?.get(key)?.jsonObject ?: return emptyMap() return obj.entries.mapNotNull { (name, value) -> 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 302e3bb9f3b..0192304ef4a 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 @@ -71,6 +71,11 @@ class KiloSessionRpcApiImpl : KiloSessionRpcApi { workspaces.get(dir).deleteSession(id) } + override suspend fun rename(id: String, directory: String, title: String): ai.kilocode.rpc.dto.SessionDto { + val dir = sessions.getDirectory(id, directory) + return sessions.rename(id, dir, title) + } + override suspend fun cloudSessions(directory: String, cursor: String?, limit: Int, gitUrl: String?): CloudSessionListDto = sessions.cloudSessions(directory, cursor, limit, gitUrl) diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendSessionManagerTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendSessionManagerTest.kt index 0f653f89f11..8419d47340d 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendSessionManagerTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendSessionManagerTest.kt @@ -20,6 +20,7 @@ import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -472,6 +473,134 @@ class KiloBackendSessionManagerTest { assertNotNull(result) } + // ------ Session rename ------ + + @Test + fun `rename patches session title and returns updated session`() = runBlocking { + mock.sessionRenameResponse = """{ + "id": "ses_1", + "slug": "s1", + "projectID": "prj_test", + "directory": "/test", + "title": "New Name", + "version": "1.0.0", + "time": {"created": 1000, "updated": 2000} + }""" + val app = setup() + ready(app) + + val session = app.sessions.rename("ses_1", "/test", "New Name") + + assertEquals("ses_1", session.id) + assertEquals("New Name", session.title) + val path = mock.lastSessionRenamePath ?: error("missing rename request") + assertTrue(path.startsWith("/session/ses_1?"), "Expected /session/ses_1?... got $path") + assertTrue(path.contains("directory=%2Ftest"), "Expected directory=/test in $path") + assertEquals("PATCH", mock.lastSessionRenameMethod) + assertEquals("""{"title":"New Name"}""", mock.lastSessionRenameBody) + } + + @Test + fun `rename response preserves directory parentId summary and timestamps`() = runBlocking { + mock.sessionRenameResponse = """{ + "id": "ses_1", + "slug": "s1", + "projectID": "prj_test", + "directory": "/worktree/path", + "title": "Renamed", + "version": "2.0.0", + "time": {"created": 1000, "updated": 9999}, + "parentID": "ses_parent", + "summary": {"additions": 5, "deletions": 3, "files": 2} + }""" + val app = setup() + ready(app) + + val session = app.sessions.rename("ses_1", "/test", "Renamed") + + assertEquals("/worktree/path", session.directory) + assertEquals("ses_parent", session.parentID) + assertEquals(1000.0, session.time.created) + assertEquals(9999.0, session.time.updated) + assertNotNull(session.summary) + assertEquals(5, session.summary!!.additions) + assertEquals(3, session.summary!!.deletions) + assertEquals(2, session.summary!!.files) + } + + @Test + fun `rename url-encodes session id and directory for special characters`() = runBlocking { + // Session IDs/directories may contain spaces, slashes, plus signs, and ampersands + val app = setup() + ready(app) + + app.sessions.rename("ses_a/b c", "/my dir/project", "New Name") + + val path = mock.lastSessionRenamePath ?: error("missing rename request") + assertTrue(path.startsWith("/session/ses_a%2Fb%20c?"), "Expected encoded session path in $path") + assertTrue(path.contains("directory=%2Fmy%20dir%2Fproject"), "Expected encoded directory in $path") + } + + @Test + fun `rename url-encodes ampersand plus and query separators`() = runBlocking { + val app = setup() + ready(app) + + app.sessions.rename("ses_a+b&c?d", "/path?a=1&b=2", "Title") + + val path: String = mock.lastSessionRenamePath ?: error("missing rename request") + val bare = path.substringBefore("?") + val query = path.substringAfter("?", "") + assertTrue(path.contains("/session/ses_a+b&c%3Fd?"), "Unexpected encoded session id: $path") + assertFalse(query.contains("/path?a=1&b=2"), "Directory must be encoded as one query value: $query") + assertTrue(query.contains("directory=%2Fpath%3Fa%3D1%26b%3D2"), "Unexpected encoded directory: $query") + } + + @Test + fun `rename encodes title in json body`() = runBlocking { + val app = setup() + ready(app) + + app.sessions.rename("ses_1", "/test", "Has \"quotes\" and \\ backslash") + + assertEquals("""{"title":"Has \"quotes\" and \\ backslash"}""", mock.lastSessionRenameBody) + } + + @Test + fun `rename encodes title control characters in json body`() = runBlocking { + val app = setup() + ready(app) + + app.sessions.rename("ses_1", "/test", "Line\nTab\tReturn\rBell\u0007") + + assertEquals("""{"title":"Line\nTab\tReturn\rBell\u0007"}""", mock.lastSessionRenameBody) + } + + @Test + fun `rename surfaces server failure`() = runBlocking { + mock.sessionRenameStatus = 500 + mock.sessionRenameResponse = """{"error":"boom"}""" + val app = setup() + ready(app) + + val err = assertFailsWith { + app.sessions.rename("ses_1", "/test", "New Name") + } + + assertTrue(err.message.orEmpty().contains("HTTP 500")) + assertTrue(err.message.orEmpty().contains("boom")) + } + + @Test + fun `rename throws before start`() = runBlocking { + val app = setup() + // Don't connect — manager is not started + + assertFailsWith { + app.sessions.rename("ses_1", "/test", "Title") + } + } + // ------ Session with summary ------ @Test @@ -496,4 +625,32 @@ class KiloBackendSessionManagerTest { assertEquals(7, session.summary!!.deletions) assertEquals(3, session.summary!!.files) } + + @Test + fun `status and summary long values clamp to shared dto int range`() = runBlocking { + mock.sessions = """[{ + "id": "ses_big", + "slug": "big", + "projectID": "prj", + "directory": "/d", + "title": "Big", + "version": "1", + "time": {"created": 1, "updated": 1}, + "summary": {"additions": 2147483648, "deletions": 9223372036854775807, "files": 3} + }]""" + mock.sessionStatuses = """{ + "ses_big": {"type":"retry","attempt":2147483648,"message":"retrying","next":9223372036854775807,"requestID":"req"} + }""" + val app = setup() + ready(app) + + val result = app.sessions.list("/d") + val session = result.sessions[0] + val status = result.statuses["ses_big"] ?: error("missing status") + + assertEquals(Int.MAX_VALUE, session.summary?.additions) + assertEquals(Int.MAX_VALUE, session.summary?.deletions) + assertEquals(Int.MAX_VALUE, status.attempt) + assertEquals(Long.MAX_VALUE, status.next) + } } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/GeneratedApiModelSerializationTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/GeneratedApiModelSerializationTest.kt index 7ddd445175f..b19ed6072dd 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/GeneratedApiModelSerializationTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/GeneratedApiModelSerializationTest.kt @@ -39,7 +39,7 @@ class GeneratedApiModelSerializationTest { @Test fun `empty provider list`() { - val src = """{"all":[],"default":{},"connected":[]}""" + val src = """{"all":[],"default":{},"connected":[],"failed":[]}""" val obj = json.decodeFromString(src) assertTrue(obj.all.isEmpty()) assertTrue(obj.default.isEmpty()) @@ -81,7 +81,8 @@ class GeneratedApiModelSerializationTest { } }], "default": {"code": "anthropic/claude-4"}, - "connected": ["anthropic"] + "connected": ["anthropic"], + "failed": [] }""" val obj = json.decodeFromString(src) assertEquals(1, obj.all.size) @@ -132,7 +133,8 @@ class GeneratedApiModelSerializationTest { } }], "default": {}, - "connected": [] + "connected": [], + "failed": [] }""" val obj = json.decodeFromString(src) val model = obj.all[0].models["free-model"]!! @@ -149,12 +151,22 @@ class GeneratedApiModelSerializationTest { "all": [], "default": {}, "connected": [], + "failed": [], "future_field": "value" }""" val obj = json.decodeFromString(src) assertTrue(obj.all.isEmpty()) } + @Test + fun `provider list defaults omitted collection fields`() { + val obj = json.decodeFromString("{}") + assertTrue(obj.all.isEmpty()) + assertTrue(obj.default.isEmpty()) + assertTrue(obj.connected.isEmpty()) + assertTrue(obj.failed.isEmpty()) + } + // ------ Agent ------ @Test @@ -287,4 +299,70 @@ class GeneratedApiModelSerializationTest { val list = json.decodeFromString>("[]") assertTrue(list.isEmpty()) } + + // ------ ProviderList200Response.failed non-empty ------ + + @Test + fun `provider list failed field handles non-empty list`() { + val src = """{ + "all": [], + "default": {}, + "connected": [], + "failed": ["openai", "anthropic"] + }""" + val obj = json.decodeFromString(src) + assertEquals(listOf("openai", "anthropic"), obj.failed) + } + + @Test + fun `provider list failed field handles single failed provider`() { + val src = """{"all":[],"default":{},"connected":[],"failed":["openai"]}""" + val obj = json.decodeFromString(src) + assertEquals(listOf("openai"), obj.failed) + } + + // ------ Model limits with realistic large values ------ + + @Test + fun `model limit context and output deserialize with large values`() { + val src = """{ + "all": [{ + "id": "p", + "name": "Provider", + "source": "api", + "env": [], + "options": {}, + "models": { + "big-model": { + "id": "big-model", + "providerID": "p", + "name": "Big Model", + "api": {"id": "p", "url": "", "npm": ""}, + "capabilities": { + "temperature": true, + "reasoning": false, + "attachment": false, + "toolcall": true, + "input": {"text": true, "audio": false, "image": false, "video": false, "pdf": false}, + "output": {"text": true, "audio": false, "image": false, "video": false, "pdf": false}, + "interleaved": {"field":"reasoning_content"} + }, + "cost": {"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}, + "limit": {"context": 1000000, "output": 32000}, + "status": "active", + "options": {}, + "headers": {}, + "release_date": "2025-01-01" + } + } + }], + "default": {}, + "connected": [], + "failed": [] + }""" + val obj = json.decodeFromString(src) + val model = obj.all[0].models["big-model"]!! + assertEquals(1000000.0, model.limit.context) + assertEquals(32000.0, model.limit.output) + } } 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 6e6550256d6..6b1f3c980fe 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 @@ -836,6 +836,21 @@ class KiloCliDataParserTest { assertEquals(5000L, result.status.next) } + @Test + fun `parseChatEvent - session status clamps large attempt`() { + val data = globalEvent(""" + "type": "session.status", + "properties": { + "sessionID": "ses_1", + "status": {"type": "retry", "message": "Retrying...", "attempt": 2147483648, "next": 9223372036854775807} + } + """) + + val result = KiloCliDataParser.parseChatEvent("session.status", data) as ChatEventDto.SessionStatusChanged + assertEquals(Int.MAX_VALUE, result.status.attempt) + assertEquals(Long.MAX_VALUE, result.status.next) + } + @Test fun `parseChatEvent - session status offline with requestID`() { val data = globalEvent(""" @@ -970,6 +985,21 @@ class KiloCliDataParserTest { assertEquals(3, result.diff[0].additions) } + @Test + fun `parseChatEvent - session diff clamps large counts`() { + val data = globalEvent(""" + "type": "session.diff", + "properties": { + "sessionID": "ses_1", + "diff": [{"file": "src/A.kt", "additions": 2147483648, "deletions": 9223372036854775807, "patch": "@@ ..."}] + } + """) + + val result = KiloCliDataParser.parseChatEvent("session.diff", data) as ChatEventDto.SessionDiffChanged + assertEquals(Int.MAX_VALUE, result.diff[0].additions) + assertEquals(Int.MAX_VALUE, result.diff[0].deletions) + } + // ================================================================ // parseChatEvent — part with callID // ================================================================ diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ProjectModelSerializationTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ProjectModelSerializationTest.kt index 093b62e9c6a..35ba6f52e34 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ProjectModelSerializationTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ProjectModelSerializationTest.kt @@ -23,7 +23,7 @@ class ProjectModelSerializationTest { @Test fun `empty provider list`() { - val src = """{"all":[],"default":{},"connected":[]}""" + val src = """{"all":[],"default":{},"connected":[],"failed":[]}""" val obj = json.decodeFromString(src) assertTrue(obj.all.isEmpty()) assertTrue(obj.default.isEmpty()) @@ -64,7 +64,8 @@ class ProjectModelSerializationTest { } }], "default": {"code": "anthropic/claude-4"}, - "connected": ["anthropic"] + "connected": ["anthropic"], + "failed": [] }""" val obj = json.decodeFromString(src) assertEquals(1, obj.all.size) @@ -115,7 +116,8 @@ class ProjectModelSerializationTest { } }], "default": {}, - "connected": [] + "connected": [], + "failed": [] }""" val obj = json.decodeFromString(src) val model = obj.all[0].models["free-model"]!! @@ -132,12 +134,20 @@ class ProjectModelSerializationTest { "all": [], "default": {}, "connected": [], + "failed": [], "future_field": "value" }""" val obj = json.decodeFromString(src) assertTrue(obj.all.isEmpty()) } + @Test + fun `provider list defaults omitted failed field`() { + val src = """{"all":[],"default":{},"connected":[]}""" + val obj = json.decodeFromString(src) + assertTrue(obj.failed.isEmpty()) + } + // ------ Agent ------ @Test @@ -270,4 +280,18 @@ class ProjectModelSerializationTest { val list = json.decodeFromString>("[]") assertTrue(list.isEmpty()) } + + // ------ ProviderList200Response.failed non-empty ------ + + @Test + fun `provider list failed field handles non-empty list`() { + val src = """{ + "all": [], + "default": {}, + "connected": [], + "failed": ["openai", "gemini"] + }""" + val obj = json.decodeFromString(src) + assertEquals(listOf("openai", "gemini"), obj.failed) + } } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/SessionModelSerializationTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/SessionModelSerializationTest.kt index 398a1e4c4aa..e837d2693b9 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/SessionModelSerializationTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/SessionModelSerializationTest.kt @@ -26,7 +26,7 @@ class SessionModelSerializationTest { "directory": "/test/project", "title": "My Session", "version": "1.0.0", - "time": {"created": 1000.0, "updated": 2000.0} + "time": {"created": 1000, "updated": 2000} }""" val obj = json.decodeFromString(src) assertEquals("ses_abc", obj.id) @@ -34,8 +34,8 @@ class SessionModelSerializationTest { assertEquals("prj_123", obj.projectID) assertEquals("/test/project", obj.directory) assertEquals("My Session", obj.title) - assertEquals(1000.0, obj.time.created) - assertEquals(2000.0, obj.time.updated) + assertEquals(1000L, obj.time.created) + assertEquals(2000L, obj.time.updated) assertNull(obj.parentID) assertNull(obj.summary) } @@ -49,14 +49,14 @@ class SessionModelSerializationTest { "directory": "/d", "title": "T", "version": "1.0.0", - "time": {"created": 1.0, "updated": 2.0}, + "time": {"created": 1, "updated": 2}, "summary": {"additions": 10, "deletions": 5, "files": 3} }""" val obj = json.decodeFromString(src) assertNotNull(obj.summary) - assertEquals(10.0, obj.summary!!.additions) - assertEquals(5.0, obj.summary!!.deletions) - assertEquals(3.0, obj.summary!!.files) + assertEquals(10, obj.summary!!.additions) + assertEquals(5, obj.summary!!.deletions) + assertEquals(3, obj.summary!!.files) } @Test @@ -68,7 +68,7 @@ class SessionModelSerializationTest { "directory": "/d", "title": "Fork", "version": "1.0.0", - "time": {"created": 1.0, "updated": 2.0, "archived": 3000.0}, + "time": {"created": 1, "updated": 2, "archived": 3000.0}, "parentID": "ses_parent" }""" val obj = json.decodeFromString(src) @@ -132,9 +132,9 @@ class SessionModelSerializationTest { val src = """{"type":"retry","attempt":2,"message":"Rate limited","next":1500,"requestID":"req_1"}""" val obj = json.decodeFromString(src) assertEquals(SessionStatus.Type.RETRY, obj.type) - assertEquals(2.0, obj.attempt) + assertEquals(2L, obj.attempt) assertEquals("Rate limited", obj.message) - assertEquals(1500.0, obj.next) + assertEquals(1500L, obj.next) } @Test @@ -162,4 +162,69 @@ class SessionModelSerializationTest { val map = json.decodeFromString>("{}") assertTrue(map.isEmpty()) } + + // ------ Large integer (Long) handling ------ + + @Test + fun `Session time created and updated deserialize as Long for values larger than Int MAX_VALUE`() { + val big = Int.MAX_VALUE.toLong() + 12345L + val src = """{ + "id": "ses_big", + "slug": "big", + "projectID": "prj", + "directory": "/d", + "title": "Big Times", + "version": "1", + "time": {"created": $big, "updated": ${big + 1L}} + }""" + val obj = json.decodeFromString(src) + assertEquals(big, obj.time.created) + assertEquals(big + 1L, obj.time.updated) + } + + @Test + fun `Session time millisecond epoch timestamps deserialize correctly`() { + // Realistic millisecond timestamp: 2026-01-01T00:00:00Z = 1767139200000 + val ms = 1767139200000L + val src = """{ + "id": "ses_ms", + "slug": "ms", + "projectID": "prj", + "directory": "/d", + "title": "MS Times", + "version": "1", + "time": {"created": $ms, "updated": $ms} + }""" + val obj = json.decodeFromString(src) + assertEquals(ms, obj.time.created) + assertEquals(ms, obj.time.updated) + assertTrue(obj.time.created > Int.MAX_VALUE) + } + + @Test + fun `Session time defaults to 0L when fields are omitted`() { + // The generated model has default = 0L for created and updated + val obj = ai.kilocode.jetbrains.api.model.SessionTime() + assertEquals(0L, obj.created) + assertEquals(0L, obj.updated) + } + + @Test + fun `SessionStatus attempt and next deserialize as Long for large values`() { + val bigAttempt = Int.MAX_VALUE.toLong() + 5L + val bigNext = Int.MAX_VALUE.toLong() + 99999L + val src = """{"type":"retry","attempt":$bigAttempt,"message":"Retrying","next":$bigNext,"requestID":"req_1"}""" + val obj = json.decodeFromString(src) + assertEquals(SessionStatus.Type.RETRY, obj.type) + assertEquals(bigAttempt, obj.attempt) + assertEquals(bigNext, obj.next) + } + + @Test + fun `SessionStatus defaults attempt and next to 0L when omitted`() { + val src = """{"type":"idle","message":"","requestID":""}""" + val obj = json.decodeFromString(src) + assertEquals(0L, obj.attempt) + assertEquals(0L, obj.next) + } } 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 5b234053a70..e98c52c0429 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 @@ -41,7 +41,7 @@ class MockCliServer : AutoCloseable { @Volatile var notificationsStatus = 200 // Project-scoped REST responses - @Volatile var providers = """{"all":[],"default":{},"connected":[]}""" + @Volatile var providers = """{"all":[],"default":{},"connected":[],"failed":[]}""" @Volatile var agents = "[]" @Volatile var commands = "[]" @Volatile var skills = "[]" @@ -72,6 +72,11 @@ class MockCliServer : AutoCloseable { @Volatile var summarizeStatus = 200 @Volatile var lastSummarizePath: String? = null @Volatile var lastSummarizeBody: String? = null + @Volatile var sessionRenameStatus = 200 + @Volatile var sessionRenameResponse = """{"id":"ses_test","slug":"test","projectID":"prj_test","directory":"/test","title":"Renamed","version":"1.0.0","time":{"created":1000,"updated":2000}}""" + @Volatile var lastSessionRenamePath: String? = null + @Volatile var lastSessionRenameBody: String? = null + @Volatile var lastSessionRenameMethod: String? = null /** Configurable delay for all endpoint responses (ms). 0 = no delay. */ @Volatile var responseDelay: Long = 0 @@ -239,10 +244,16 @@ class MockCliServer : AutoCloseable { bare == "/session/status" -> respond(output, sessionStatusesStatus, sessionStatuses) bare == "/session" && method == "GET" -> respond(output, sessionsStatus, sessions) bare == "/session" && method == "POST" -> respond(output, sessionCreateStatus, sessionCreate) - bare.matches(Regex("/session/ses_[^/]+")) && method == "GET" -> + bare.matches(Regex("/session/ses_.+")) && !bare.contains("/summarize") && method == "GET" -> respond(output, sessionGetStatus, sessionCreate) - bare.matches(Regex("/session/ses_[^/]+")) && method == "DELETE" -> + bare.matches(Regex("/session/ses_.+")) && !bare.contains("/summarize") && method == "DELETE" -> respond(output, sessionDeleteStatus, "true") + bare.matches(Regex("/session/ses_.+")) && !bare.contains("/summarize") && method == "PATCH" -> { + lastSessionRenamePath = path + lastSessionRenameBody = body + lastSessionRenameMethod = method + respond(output, sessionRenameStatus, sessionRenameResponse) + } bare.matches(Regex("/session/ses_[^/]+/summarize")) && method == "POST" -> { lastSummarizePath = path lastSummarizeBody = body diff --git a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/FixGeneratedApiTask.kt b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/FixGeneratedApiTask.kt index 7a39f7c2161..099d0212123 100644 --- a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/FixGeneratedApiTask.kt +++ b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/FixGeneratedApiTask.kt @@ -218,7 +218,7 @@ abstract class FixGeneratedApiTask : DefaultTask() { } } - // Fix 7: Default values for non-nullable primitives in model data classes. + // Fix 7: Default values for non-nullable primitives and collections in model data classes. // The CLI API may omit fields that the OpenAPI spec marks as required // (e.g. `attachment`, `reasoning` on dynamically added models). // Add Kotlin defaults so kotlinx.serialization doesn't throw @@ -230,6 +230,9 @@ abstract class FixGeneratedApiTask : DefaultTask() { Regex("""(val \w+:\s*kotlin\.Boolean)(,|\n)""") to { m: MatchResult -> "${m.groupValues[1]} = false${m.groupValues[2]}" }, + Regex("""(val \w+:\s*kotlin\.Long)(,|\n)""") to { m: MatchResult -> + "${m.groupValues[1]} = 0L${m.groupValues[2]}" + }, Regex("""(val \w+:\s*kotlin\.Int)(,|\n)""") to { m: MatchResult -> "${m.groupValues[1]} = 0${m.groupValues[2]}" }, @@ -239,6 +242,12 @@ abstract class FixGeneratedApiTask : DefaultTask() { Regex("""(val \w+:\s*kotlin\.String)(,|\n)""") to { m: MatchResult -> "${m.groupValues[1]} = \"\"${m.groupValues[2]}" }, + Regex("""(val \w+:\s*kotlin\.collections\.List<[^>]+>)(,|\n)""") to { m: MatchResult -> + "${m.groupValues[1]} = emptyList()${m.groupValues[2]}" + }, + Regex("""(val \w+:\s*kotlin\.collections\.Map<[^>]+>)(,|\n)""") to { m: MatchResult -> + "${m.groupValues[1]} = emptyMap()${m.groupValues[2]}" + }, ) for ((pattern, transform) in primitiveDefaults) { if (pattern.containsMatchIn(text)) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/DeleteSessionAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/DeleteSessionAction.kt new file mode 100644 index 00000000000..3db258f8cce --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/DeleteSessionAction.kt @@ -0,0 +1,47 @@ +package ai.kilocode.client.actions + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.history.HistoryDataKeys +import ai.kilocode.client.session.SessionManager +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.Messages + +class DeleteSessionAction : AnAction() { + /** Overridable in tests to avoid showing a real modal dialog. */ + internal var confirm: (project: Project?, msg: String) -> Boolean = { project, msg -> + Messages.showYesNoDialog( + project, + msg, + KiloBundle.message("history.delete.confirm.title"), + Messages.getWarningIcon(), + ) == Messages.YES + } + + override fun getActionUpdateThread() = ActionUpdateThread.EDT + + override fun update(e: AnActionEvent) { + val selection = e.getData(HistoryDataKeys.SELECTION) + val manager = e.getData(SessionManager.KEY) + e.presentation.isEnabledAndVisible = manager != null && + selection != null && + selection.selectedLocal.isNotEmpty() + } + + override fun actionPerformed(e: AnActionEvent) { + val selection = e.getData(HistoryDataKeys.SELECTION) ?: return + val controller = e.getData(HistoryDataKeys.CONTROLLER) ?: return + val items = selection.selectedLocal.filter { !controller.deleting(it) } + if (items.isEmpty()) return + + val msg = if (items.size == 1) + KiloBundle.message("history.delete.confirm.message", ai.kilocode.client.session.history.title(items[0])) + else + KiloBundle.message("history.delete.confirm.message.multiple", items.size) + + if (!confirm(e.project, msg)) return + items.forEach { controller.delete(it) } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenSessionAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenSessionAction.kt new file mode 100644 index 00000000000..c6ef6f3dc10 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenSessionAction.kt @@ -0,0 +1,26 @@ +package ai.kilocode.client.actions + +import ai.kilocode.client.session.history.HistoryDataKeys +import ai.kilocode.client.session.SessionManager +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent + +class OpenSessionAction : AnAction() { + override fun getActionUpdateThread() = ActionUpdateThread.EDT + + override fun update(e: AnActionEvent) { + val selection = e.getData(HistoryDataKeys.SELECTION) + val manager = e.getData(SessionManager.KEY) + e.presentation.isEnabledAndVisible = manager != null && + selection != null && + (selection.selectedLocal.size == 1 || selection.cloudItems.size == 1) + } + + override fun actionPerformed(e: AnActionEvent) { + val selection = e.getData(HistoryDataKeys.SELECTION) ?: return + val controller = e.getData(HistoryDataKeys.CONTROLLER) ?: return + selection.selectedLocal.singleOrNull()?.let { controller.open(it); return } + selection.cloudItems.singleOrNull()?.let { controller.open(it) } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/RenameSessionAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/RenameSessionAction.kt new file mode 100644 index 00000000000..aa40c15fb41 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/RenameSessionAction.kt @@ -0,0 +1,47 @@ +package ai.kilocode.client.actions + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.history.HistoryDataKeys +import ai.kilocode.client.session.history.title +import ai.kilocode.client.session.SessionManager +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.Messages + +class RenameSessionAction : AnAction() { + /** Overridable in tests to avoid showing a real modal dialog. */ + internal var input: (project: Project?, current: String) -> String? = { project, current -> + Messages.showInputDialog( + project, + KiloBundle.message("history.rename.prompt"), + KiloBundle.message("history.rename.title"), + null, + current, + null, + ) + } + + override fun getActionUpdateThread() = ActionUpdateThread.EDT + + override fun update(e: AnActionEvent) { + val selection = e.getData(HistoryDataKeys.SELECTION) + val manager = e.getData(SessionManager.KEY) + e.presentation.isEnabledAndVisible = manager != null && + selection != null && + selection.selectedLocal.size == 1 + } + + override fun actionPerformed(e: AnActionEvent) { + val selection = e.getData(HistoryDataKeys.SELECTION) ?: return + val controller = e.getData(HistoryDataKeys.CONTROLLER) ?: return + val item = selection.selectedLocal.singleOrNull() ?: return + + val current = title(item) + val newTitle = input(e.project, current)?.trim() ?: return + + if (newTitle.isBlank() || newTitle == current) return + controller.rename(item, newTitle) + } +} 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 a011ad885e6..4f2fdcf05f9 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 @@ -124,6 +124,12 @@ class KiloSessionService internal constructor( list(dir) } + suspend fun renameSession(id: String, dir: String, newTitle: String): ai.kilocode.rpc.dto.SessionDto { + val session = call { rename(id, dir, newTitle) } + _sessions.value = _sessions.value.map { if (it.id == id) session else it } + return session + } + suspend fun cloudSessions(dir: String, cursor: String?, limit: Int, gitUrl: String?): CloudSessionListDto = call { cloudSessions(dir, cursor, limit, gitUrl) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt index 2fa5d531c56..118c2e87d24 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt @@ -113,7 +113,7 @@ class SessionSidePanelManager( deleted = this::removeSession, ) Disposer.register(this) { cs.cancel() } - return HistoryPanel(this, controller, nav = this::back).component + return HistoryPanel(this, controller, nav = this::back, manager = this).component } private fun back() { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryController.kt index 81993348e78..a416a684642 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryController.kt @@ -61,9 +61,10 @@ class HistoryController( if (item.id in deleting) return@edt deleting.add(item.id) local.refresh() + val dir = item.directory ?: workspace.directory cs.launch { try { - sessions.deleteSession(item.id, item.directory ?: workspace.directory) + sessions.deleteSession(item.id, dir) edt { deleting.remove(item.id) local.remove(item.id) @@ -79,6 +80,18 @@ class HistoryController( } } + fun rename(item: LocalHistoryItem, title: String) { + val dir = item.directory ?: workspace.directory + cs.launch { + try { + val updated = sessions.renameSession(item.id, dir, title) + edt { local.update(LocalHistoryItem(updated)) } + } catch (e: Exception) { + edt { local.fail(e.message ?: KiloBundle.message("history.error.local.rename")) } + } + } + } + fun deleting(item: LocalHistoryItem): Boolean = item.id in deleting fun open(item: LocalHistoryItem) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryDataKeys.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryDataKeys.kt new file mode 100644 index 00000000000..3c560038abc --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryDataKeys.kt @@ -0,0 +1,16 @@ +package ai.kilocode.client.session.history + +import com.intellij.openapi.actionSystem.DataKey + +data class HistorySelection( + val source: HistorySource, + val localItems: List, + val cloudItems: List, +) { + val selectedLocal: List get() = if (source == HistorySource.LOCAL) localItems else emptyList() +} + +object HistoryDataKeys { + val SELECTION: DataKey = DataKey.create("ai.kilocode.client.session.history.HistorySelection") + val CONTROLLER: DataKey = DataKey.create("ai.kilocode.client.session.history.HistoryController") +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryListRenderer.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryListRenderer.kt index a7fb4e2c961..4aae232943a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryListRenderer.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryListRenderer.kt @@ -3,7 +3,6 @@ package ai.kilocode.client.session.history import ai.kilocode.client.session.ui.PickerRow import ai.kilocode.client.ui.UiStyle import com.intellij.icons.AllIcons -import com.intellij.ui.ExperimentalUI import com.intellij.ui.GroupHeaderSeparator import com.intellij.ui.SimpleColoredComponent import com.intellij.ui.SimpleTextAttributes @@ -20,7 +19,7 @@ import javax.swing.JPanel import javax.swing.ListCellRenderer import javax.swing.SwingConstants -private const val DELETE_CLICK_AREA_WIDTH = 32 +private const val DELETE_AREA_WIDTH = 32 internal open class HistoryRenderer( private val model: HistoryModel, @@ -31,14 +30,14 @@ internal open class HistoryRenderer( private val empty: Icon = EmptyIcon.create(icon) fun isDeleteClick(list: JList<*>, bounds: Rectangle, point: Point): Boolean { - val width = JBUI.scale(DELETE_CLICK_AREA_WIDTH) - val inset = deleteInset(list) - if (list.componentOrientation.isLeftToRight) { - val right = bounds.x + bounds.width - inset - return point.x in (right - width)..right + val width = JBUI.scale(DELETE_AREA_WIDTH) + return if (list.componentOrientation.isLeftToRight) { + val right = bounds.x + bounds.width + point.x in (right - width)..right + } else { + val left = bounds.x + point.x in left..(left + width) } - val left = bounds.x + inset - return point.x in left..(left + width) } fun section(items: List, index: Int): String? { @@ -48,13 +47,6 @@ internal open class HistoryRenderer( if (current == previous) return null return HistoryTime.title(current) } - - private fun deleteInset(list: JList<*>): Int { - if (!ExperimentalUI.isNewUI()) return 0 - val inner = JBUI.CurrentTheme.Popup.Selection.innerInsets() - val edge = JBUI.CurrentTheme.Popup.Selection.LEFT_RIGHT_INSET.get() - return edge + if (list.componentOrientation.isLeftToRight) inner.right else inner.left - } } private val sep = GroupHeaderSeparator(JBUI.CurrentTheme.Popup.separatorLabelInsets()) @@ -75,7 +67,7 @@ internal open class HistoryRenderer( } private val row = JPanel(BorderLayout()).apply { add(main, BorderLayout.CENTER) - add(del, BorderLayout.EAST) + if (deletable) add(del, BorderLayout.EAST) } private val wrap = PickerRow() @@ -114,13 +106,11 @@ internal open class HistoryRenderer( ) time.text = value?.let(HistoryTime::relative).orEmpty() time.foreground = weak - del.icon = if (deletable && selected) icon else empty + if (deletable) del.icon = if (selected) icon else empty top.invalidate() return this } - - fun deleteVisible(): Boolean = del.icon === icon } internal class LocalHistoryRenderer(model: HistoryModel) : HistoryRenderer(model, deletable = true) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryListUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryListUi.kt index c5c9b2a6be4..df01ec2b823 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryListUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryListUi.kt @@ -6,10 +6,9 @@ import java.awt.event.MouseEvent internal fun title(item: HistoryItem): String = item.title.takeIf { it.isNotBlank() } ?: KiloBundle.message("history.untitled") -internal fun clicked(list: JBList, e: MouseEvent): T? { +internal fun itemAt(list: JBList, e: MouseEvent): T? { val row = list.locationToIndex(e.point) val box = row.takeIf { it >= 0 }?.let { list.getCellBounds(it, it) } ?: return null if (!box.contains(e.point)) return null - list.selectedIndex = row return list.model.getElementAt(row) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryModel.kt index a9d0af9e774..ec457e8a5a1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryModel.kt @@ -48,6 +48,12 @@ open class HistoryModel : AbstractListModel() { filter() } + fun update(item: T) { + if (all.none { it.id == item.id }) return + all = HistoryTime.sorted(all.map { if (it.id == item.id) item else it }) + filter() + } + fun fail(message: String) { loading = false loaded = true diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryPanel.kt index 4c4460acd28..c27bc0a7524 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryPanel.kt @@ -1,6 +1,7 @@ package ai.kilocode.client.session.history import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.SessionManager import ai.kilocode.client.session.ui.LoadingPanel import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.ui.UiStyle @@ -9,10 +10,13 @@ import com.intellij.ide.ui.LafManagerListener import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.Disposable -import com.intellij.openapi.ui.Messages +import com.intellij.openapi.actionSystem.ActionGroup +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.ActionPlaces +import com.intellij.openapi.actionSystem.DataProvider import com.intellij.openapi.util.Disposer import com.intellij.ui.DocumentAdapter -import com.intellij.ui.ListUtil +import com.intellij.ui.PopupHandler import com.intellij.ui.SearchTextField import com.intellij.ui.ScrollingUtil import com.intellij.ui.components.JBList @@ -51,7 +55,8 @@ class HistoryPanel( private val controller: HistoryController, private val gitUrl: () -> String? = { null }, private val nav: () -> Unit = {}, -) : BorderLayoutPanel(), Disposable { + private val manager: SessionManager? = null, +) : BorderLayoutPanel(), Disposable, DataProvider { private val localSearch = search(controller.local) private val cloudSearch = search(controller.cloud) private val localList = localList() @@ -188,21 +193,22 @@ class HistoryPanel( } private fun localList() = JBList(controller.local).apply { - selectionMode = ListSelectionModel.SINGLE_SELECTION - isFocusable = false + selectionMode = ListSelectionModel.MULTIPLE_INTERVAL_SELECTION + isFocusable = true cellRenderer = LocalHistoryRenderer(controller.local) cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) emptyText.text = KiloBundle.message("history.empty") addMouseListener(object : MouseAdapter() { - override fun mouseReleased(e: MouseEvent) { - if (!UIUtil.isActionClick(e, MouseEvent.MOUSE_RELEASED, true)) return - val item = clicked(this@apply, e) ?: return - if (deleteClick(this@apply, e)) { + override fun mouseClicked(e: MouseEvent) { + val row = locationToIndex(e.point) + val box = row.takeIf { it >= 0 }?.let { getCellBounds(it, it) } ?: return + if (!box.contains(e.point)) return + if (e.clickCount == 1 && HistoryRenderer.isDeleteClick(this@apply, box, e.point)) { + val item = model.getElementAt(row) confirm(item) - e.consume() - return + } else if (e.clickCount == 2) { + selectedValue?.let(::activate) } - activate(item) } }) registerKeyboardAction( @@ -210,20 +216,19 @@ class HistoryPanel( KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), JComponent.WHEN_FOCUSED, ) - ListUtil.installAutoSelectOnMouseMove(this) + installContextMenu(this) ScrollingUtil.installActions(this) } private fun cloudList() = JBList(controller.cloud).apply { selectionMode = ListSelectionModel.SINGLE_SELECTION - isFocusable = false + isFocusable = true cellRenderer = CloudHistoryRenderer(controller.cloud) cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) emptyText.text = KiloBundle.message("history.empty") addMouseListener(object : MouseAdapter() { - override fun mouseReleased(e: MouseEvent) { - if (!UIUtil.isActionClick(e, MouseEvent.MOUSE_RELEASED, true)) return - clicked(this@apply, e)?.let(::activate) + override fun mouseClicked(e: MouseEvent) { + if (e.clickCount == 2) selectedValue?.let(::activate) } }) registerKeyboardAction( @@ -231,7 +236,7 @@ class HistoryPanel( KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), JComponent.WHEN_FOCUSED, ) - ListUtil.installAutoSelectOnMouseMove(this) + installContextMenu(this) ScrollingUtil.installActions(this) } @@ -272,13 +277,6 @@ class HistoryPanel( } } - private fun deleteClick(list: JBList, e: MouseEvent): Boolean { - val row = list.locationToIndex(e.point) - val box = row.takeIf { it >= 0 }?.let { list.getCellBounds(it, it) } ?: return false - if (!box.contains(e.point)) return false - return HistoryRenderer.isDeleteClick(list, box, e.point) - } - private fun activate(item: HistoryItem) { when (item) { is LocalHistoryItem -> controller.open(item) @@ -286,18 +284,54 @@ class HistoryPanel( } } + override fun getData(dataId: String): Any? { + if (SessionManager.KEY.`is`(dataId)) return manager + if (HistoryDataKeys.CONTROLLER.`is`(dataId)) return controller + if (HistoryDataKeys.SELECTION.`is`(dataId)) { + val source = selectedSource() + val local = if (source == HistorySource.LOCAL) localList.selectedValuesList.filterIsInstance() else emptyList() + val cloud = if (source == HistorySource.CLOUD) cloudList.selectedValuesList.filterIsInstance() else emptyList() + return HistorySelection(source, local, cloud) + } + return null + } + + private fun installContextMenu(list: JBList) { + val group = ActionManager.getInstance().getAction("Kilo.History.ContextMenu") + if (group is ActionGroup) { + PopupHandler.installPopupMenu(list, group, ActionPlaces.POPUP) + } + } + private fun confirm(item: LocalHistoryItem) { if (controller.deleting(item)) return - val result = Messages.showYesNoDialog( + val result = com.intellij.openapi.ui.Messages.showYesNoDialog( this, KiloBundle.message("history.delete.confirm.message", title(item)), KiloBundle.message("history.delete.confirm.title"), - Messages.getWarningIcon(), + com.intellij.openapi.ui.Messages.getWarningIcon(), ) - if (result != Messages.YES) return + if (result != com.intellij.openapi.ui.Messages.YES) return controller.delete(item) } + internal fun confirmDelete(items: List) { + val active = items.filter { !controller.deleting(it) } + if (active.isEmpty()) return + val msg = if (active.size == 1) + KiloBundle.message("history.delete.confirm.message", title(active[0])) + else + KiloBundle.message("history.delete.confirm.message.multiple", active.size) + val result = com.intellij.openapi.ui.Messages.showYesNoDialog( + this, + msg, + KiloBundle.message("history.delete.confirm.title"), + com.intellij.openapi.ui.Messages.getWarningIcon(), + ) + if (result != com.intellij.openapi.ui.Messages.YES) return + active.forEach { controller.delete(it) } + } + internal fun itemCount() = activeModel().size internal fun selectedSource() = if (tabs.selectedInfo === cloudInfo) HistorySource.CLOUD else HistorySource.LOCAL @@ -306,10 +340,18 @@ class HistoryPanel( activeList().selectedIndex = index } + internal fun selectIndices(vararg indices: Int) { + activeList().selectedIndices = indices + } + internal fun selectedIndex() = activeList().selectedIndex internal fun listFocusable() = activeList().isFocusable + internal fun listSelectionMode() = activeList().selectionMode + + internal fun loadMoreFocusable() = more.isFocusable + internal fun listCursor() = activeList().cursor.type internal fun backText(): String? { @@ -328,7 +370,8 @@ class HistoryPanel( } internal fun clickDelete() { - localList.selectedValue?.let(controller::delete) + val items = localList.selectedValuesList.filterIsInstance() + items.forEach { controller.delete(it) } } internal fun clickCloud() { @@ -354,18 +397,6 @@ class HistoryPanel( return items.indices.mapNotNull { HistoryRenderer.section(items, it) } } - internal fun deleteVisible(index: Int, selected: Boolean = true): Boolean { - val item = controller.local.getElementAt(index) - val view = localList.cellRenderer.getListCellRendererComponent(localList, item, index, selected, false) - return view is HistoryRenderer<*> && view.deleteVisible() - } - - internal fun cloudDeleteVisible(index: Int, selected: Boolean = true): Boolean { - val item = controller.cloud.getElementAt(index) - val view = cloudList.cellRenderer.getListCellRendererComponent(cloudList, item, index, selected, false) - return view is HistoryRenderer<*> && view.deleteVisible() - } - private fun activeList(): JBList = if (tabs.selectedInfo === cloudInfo) cloudList else localList private fun activeModel(): HistoryModel = if (tabs.selectedInfo === cloudInfo) controller.cloud else controller.local @@ -392,8 +423,7 @@ class HistoryPanel( private var over = false init { - isFocusable = false - setRequestFocusEnabled(false) + isFocusable = true isContentAreaFilled = false isBorderPainted = false isOpaque = false diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt index d2811072a28..3d079596616 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt @@ -4,7 +4,7 @@ import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.SessionRef import ai.kilocode.client.session.history.HistoryTime import ai.kilocode.client.session.history.LocalHistoryItem -import ai.kilocode.client.session.history.clicked +import ai.kilocode.client.session.history.itemAt import ai.kilocode.client.session.history.title import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget @@ -71,7 +71,7 @@ class EmptySessionPanel( emptyText.clear() addMouseListener(object : MouseAdapter() { override fun mouseClicked(e: MouseEvent) { - val item = clicked(this@apply, e) ?: return + val item = itemAt(this@apply, e) ?: return controller.openSession(SessionRef.Local(item.session)) } 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 e559d1b5d23..615f52df59e 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 @@ -108,7 +108,7 @@ class SessionHeaderPanel( add(output) add(Box.createHorizontalStrut(UiStyle.Gap.sm())) add(cacheRead) - add(Box.createHorizontalStrut(UiStyle.Gap.small())) + add(Box.createHorizontalStrut(UiStyle.Gap.sm())) add(cacheWrite) } private val todoRow = JPanel(FlowLayout(FlowLayout.LEFT, UiStyle.Gap.md(), 0)).apply { 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 5eabe4727d6..dedf3ee3045 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 @@ -58,5 +58,25 @@ + + + + + + + + + + + + + + + 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 5b3f9b7aaaf..298028014b7 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -92,6 +92,9 @@ history.untitled=Untitled history.delete.text=Delete history.delete.confirm.title=Delete session? history.delete.confirm.message=Delete "{0}" from local history? +history.delete.confirm.message.multiple=Delete {0} sessions from local history? +history.rename.title=Rename Session +history.rename.prompt=New session name: history.cloud.load.more=Load more history.cloud.repo.only=Only this repository history.group.today=Today @@ -108,6 +111,7 @@ history.time.years={0}y ago history.error.local=Failed to load local history history.error.cloud=Failed to load cloud history history.error.local.delete=Failed to delete session +history.error.local.rename=Failed to rename session history.error.cloud.delete=Cloud sessions cannot be deleted yet action.Kilo.Settings.text=Settings @@ -126,3 +130,10 @@ action.Kilo.Restart.text=Restart Kilo action.Kilo.Restart.description=Kill and restart the CLI process action.Kilo.Reinstall.text=Reinstall Kilo action.Kilo.Reinstall.description=Re-extract the CLI binary and restart +action.Kilo.Session.Open.text=Open +action.Kilo.Session.Open.description=Open the selected session +action.Kilo.Session.Rename.text=Rename +action.Kilo.Session.Rename.description=Rename the selected session +action.Kilo.Session.Delete.text=Delete +action.Kilo.Session.Delete.description=Delete the selected session(s) +action.Kilo.History.ContextMenu.text=History Actions diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/HistorySessionActionsTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/HistorySessionActionsTest.kt new file mode 100644 index 00000000000..9165e8d50ef --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/HistorySessionActionsTest.kt @@ -0,0 +1,488 @@ +package ai.kilocode.client.actions + +import ai.kilocode.client.app.KiloSessionService +import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.app.Workspace +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.SessionManager +import ai.kilocode.client.session.SessionRef +import ai.kilocode.client.session.history.CloudHistoryItem +import ai.kilocode.client.session.history.HistoryController +import ai.kilocode.client.session.history.HistoryDataKeys +import ai.kilocode.client.session.history.HistorySelection +import ai.kilocode.client.session.history.HistorySource +import ai.kilocode.client.session.history.LocalHistoryItem +import ai.kilocode.client.testing.FakeSessionRpcApi +import ai.kilocode.client.testing.FakeWorkspaceRpcApi +import ai.kilocode.rpc.dto.CloudSessionDto +import ai.kilocode.rpc.dto.KiloWorkspaceStateDto +import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import ai.kilocode.rpc.dto.SessionDto +import ai.kilocode.rpc.dto.SessionTimeDto +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.actionSystem.Presentation +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.util.ui.UIUtil +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout + +@Suppress("UnstableApiUsage") +class HistorySessionActionsTest : BasePlatformTestCase() { + private lateinit var scope: CoroutineScope + private lateinit var rpc: FakeSessionRpcApi + private lateinit var sessions: KiloSessionService + private lateinit var workspace: Workspace + private lateinit var controller: HistoryController + private lateinit var manager: FakeManager + + override fun setUp() { + super.setUp() + scope = CoroutineScope(SupervisorJob()) + rpc = FakeSessionRpcApi() + sessions = KiloSessionService(project, scope, rpc) + val workspaces = KiloWorkspaceService(scope, FakeWorkspaceRpcApi().also { + it.state.value = KiloWorkspaceStateDto(status = KiloWorkspaceStatusDto.READY) + }) + workspace = workspaces.workspace("/test") + controller = HistoryController(sessions, workspace, scope) + manager = FakeManager() + } + + override fun tearDown() { + try { + scope.cancel() + } finally { + super.tearDown() + } + } + + // ------ OpenSessionAction.update ------ + + fun `test open action enabled for single local selection`() { + val action = OpenSessionAction() + val local = localItem("ses_1") + val event = event(action, manager, selection(HistorySource.LOCAL, listOf(local)), controller) + + action.update(event) + + assertTrue(event.presentation.isEnabledAndVisible) + } + + fun `test open action enabled for single cloud selection`() { + val action = OpenSessionAction() + val item = cloudItem("cloud_1") + val event = event(action, manager, selection(HistorySource.CLOUD, emptyList(), listOf(item)), controller) + + action.update(event) + + assertTrue(event.presentation.isEnabledAndVisible) + } + + fun `test open action disabled without manager`() { + val action = OpenSessionAction() + val local = localItem("ses_1") + val event = event(action, null, selection(HistorySource.LOCAL, listOf(local)), controller) + + action.update(event) + + assertFalse(event.presentation.isEnabledAndVisible) + } + + fun `test open action disabled with no selection`() { + val action = OpenSessionAction() + val event = event(action, manager, selection(HistorySource.LOCAL, emptyList()), controller) + + action.update(event) + + assertFalse(event.presentation.isEnabledAndVisible) + } + + fun `test open action disabled with multiple local items`() { + val action = OpenSessionAction() + val items = listOf(localItem("ses_1"), localItem("ses_2")) + val event = event(action, manager, selection(HistorySource.LOCAL, items), controller) + + action.update(event) + + assertFalse(event.presentation.isEnabledAndVisible) + } + + // ------ OpenSessionAction.actionPerformed ------ + + fun `test open action performs opens local item`() { + val opened = mutableListOf() + val ctrl = HistoryController(sessions, workspace, scope, open = { ref -> + when (ref) { + is SessionRef.Local -> opened.add(ref.id) + is SessionRef.Cloud -> opened.add("cloud:${ref.id}") + } + }) + val local = localItem("ses_1") + val action = OpenSessionAction() + val event = event(action, manager, selection(HistorySource.LOCAL, listOf(local)), ctrl) + + action.actionPerformed(event) + flush() + + assertEquals(listOf("ses_1"), opened) + } + + fun `test open action performs opens cloud item`() { + val opened = mutableListOf() + val ctrl = HistoryController(sessions, workspace, scope, open = { ref -> + when (ref) { + is SessionRef.Local -> opened.add(ref.id) + is SessionRef.Cloud -> opened.add("cloud:${ref.id}") + } + }) + val item = cloudItem("cloud_1") + val action = OpenSessionAction() + val event = event(action, manager, selection(HistorySource.CLOUD, emptyList(), listOf(item)), ctrl) + + action.actionPerformed(event) + flush() + + assertEquals(listOf("cloud:cloud_1"), opened) + } + + // ------ DeleteSessionAction.update ------ + + fun `test delete action enabled for non-empty local selection`() { + val action = DeleteSessionAction() + val local = localItem("ses_1") + val event = event(action, manager, selection(HistorySource.LOCAL, listOf(local)), controller) + + action.update(event) + + assertTrue(event.presentation.isEnabledAndVisible) + } + + fun `test delete action disabled for cloud-only selection`() { + val action = DeleteSessionAction() + val item = cloudItem("cloud_1") + val event = event(action, manager, selection(HistorySource.CLOUD, emptyList(), listOf(item)), controller) + + action.update(event) + + assertFalse(event.presentation.isEnabledAndVisible) + } + + fun `test delete action disabled without manager`() { + val action = DeleteSessionAction() + val local = localItem("ses_1") + val event = event(action, null, selection(HistorySource.LOCAL, listOf(local)), controller) + + action.update(event) + + assertFalse(event.presentation.isEnabledAndVisible) + } + + fun `test delete action disabled with empty selection`() { + val action = DeleteSessionAction() + val event = event(action, manager, selection(HistorySource.LOCAL, emptyList()), controller) + + action.update(event) + + assertFalse(event.presentation.isEnabledAndVisible) + } + + // ------ DeleteSessionAction.actionPerformed ------ + + fun `test delete action deletes selected local items after confirmation`() { + rpc.listed += sessionDto("ses_1", "One") + rpc.listed += sessionDto("ses_2", "Two") + controller.reloadLocal() + flush() + + assertEquals(2, controller.local.items.size) + + val items = controller.local.items.toList() + val action = DeleteSessionAction().apply { confirm = { _, _ -> true } } + val event = event(action, manager, selection(HistorySource.LOCAL, items), controller) + + action.actionPerformed(event) + flush() + + assertEquals(listOf("ses_1", "ses_2"), rpc.deletes.map { it.first }) + assertTrue(controller.local.items.isEmpty()) + } + + fun `test delete action skips items already being deleted`() { + rpc.listed += sessionDto("ses_1", "One") + controller.reloadLocal() + flush() + + rpc.deleteGate = kotlinx.coroutines.CompletableDeferred() + val item = controller.local.items[0] + controller.delete(item) + waitFor { controller.deleting(item) } + + val action = DeleteSessionAction().apply { confirm = { _, _ -> true } } + val event = event(action, manager, selection(HistorySource.LOCAL, listOf(item)), controller) + + action.actionPerformed(event) + flush() + + assertTrue(rpc.deletes.isEmpty()) + + rpc.deleteGate?.complete(Unit) + waitFor { rpc.deletes.size == 1 } + assertEquals(listOf("ses_1"), rpc.deletes.map { it.first }) + } + + fun `test delete action cancelled when user says no`() { + rpc.listed += sessionDto("ses_1", "One") + controller.reloadLocal() + flush() + + val item = controller.local.items[0] + val action = DeleteSessionAction().apply { confirm = { _, _ -> false } } + val event = event(action, manager, selection(HistorySource.LOCAL, listOf(item)), controller) + + action.actionPerformed(event) + flush() + + assertTrue(rpc.deletes.isEmpty()) + } + + // ------ RenameSessionAction.update ------ + + fun `test rename action enabled for exactly one local item`() { + val action = RenameSessionAction() + val local = localItem("ses_1") + val event = event(action, manager, selection(HistorySource.LOCAL, listOf(local)), controller) + + action.update(event) + + assertTrue(event.presentation.isEnabledAndVisible) + } + + fun `test rename action disabled with no selection`() { + val action = RenameSessionAction() + val event = event(action, manager, selection(HistorySource.LOCAL, emptyList()), controller) + + action.update(event) + + assertFalse(event.presentation.isEnabledAndVisible) + } + + fun `test rename action disabled with multiple local items`() { + val action = RenameSessionAction() + val items = listOf(localItem("ses_1"), localItem("ses_2")) + val event = event(action, manager, selection(HistorySource.LOCAL, items), controller) + + action.update(event) + + assertFalse(event.presentation.isEnabledAndVisible) + } + + fun `test rename action disabled for cloud selection`() { + val action = RenameSessionAction() + val item = cloudItem("cloud_1") + val event = event(action, manager, selection(HistorySource.CLOUD, emptyList(), listOf(item)), controller) + + action.update(event) + + assertFalse(event.presentation.isEnabledAndVisible) + } + + // ------ RenameSessionAction.actionPerformed ------ + + fun `test rename action calls controller with trimmed changed title`() { + rpc.listed += sessionDto("ses_1", "Original") + controller.reloadLocal() + flush() + + val item = controller.local.items[0] + val action = RenameSessionAction().apply { input = { _, _ -> " Renamed " } } + val event = event(action, manager, selection(HistorySource.LOCAL, listOf(item)), controller) + + action.actionPerformed(event) + flush() + + assertEquals(listOf(Triple("ses_1", "/test", "Renamed")), rpc.renames) + } + + fun `test rename action passes displayed current title to input`() { + rpc.listed += sessionDto("ses_1", "Original") + controller.reloadLocal() + flush() + + val prompts = mutableListOf() + val item = controller.local.items[0] + val action = RenameSessionAction().apply { + input = { _, current -> + prompts.add(current) + null + } + } + val event = event(action, manager, selection(HistorySource.LOCAL, listOf(item)), controller) + + action.actionPerformed(event) + flush() + + assertEquals(listOf("Original"), prompts) + assertTrue(rpc.renames.isEmpty()) + } + + fun `test rename action passes untitled fallback to input`() { + rpc.listed += sessionDto("ses_1", "") + controller.reloadLocal() + flush() + + val prompts = mutableListOf() + val item = controller.local.items[0] + val action = RenameSessionAction().apply { + input = { _, current -> + prompts.add(current) + null + } + } + val event = event(action, manager, selection(HistorySource.LOCAL, listOf(item)), controller) + + action.actionPerformed(event) + flush() + + assertEquals(listOf(KiloBundle.message("history.untitled")), prompts) + assertTrue(rpc.renames.isEmpty()) + } + + fun `test rename action ignores blank input`() { + rpc.listed += sessionDto("ses_1", "Original") + controller.reloadLocal() + flush() + + val item = controller.local.items[0] + val action = RenameSessionAction().apply { input = { _, _ -> " " } } + val event = event(action, manager, selection(HistorySource.LOCAL, listOf(item)), controller) + + action.actionPerformed(event) + flush() + + assertTrue(rpc.renames.isEmpty()) + } + + fun `test rename action ignores unchanged input`() { + rpc.listed += sessionDto("ses_1", "Original") + controller.reloadLocal() + flush() + + val item = controller.local.items[0] + val action = RenameSessionAction().apply { input = { _, _ -> "Original" } } + val event = event(action, manager, selection(HistorySource.LOCAL, listOf(item)), controller) + + action.actionPerformed(event) + flush() + + assertTrue(rpc.renames.isEmpty()) + } + + fun `test rename action ignores null input`() { + rpc.listed += sessionDto("ses_1", "Original") + controller.reloadLocal() + flush() + + val item = controller.local.items[0] + val action = RenameSessionAction().apply { input = { _, _ -> null } } + val event = event(action, manager, selection(HistorySource.LOCAL, listOf(item)), controller) + + action.actionPerformed(event) + flush() + + assertTrue(rpc.renames.isEmpty()) + } + + fun `test frontend descriptor registers history actions`() { + val xml = javaClass.classLoader.getResourceAsStream("kilo.jetbrains.frontend.xml") + ?.bufferedReader() + ?.use { it.readText() } + ?: error("missing frontend descriptor") + + assertTrue(xml.contains("id=\"Kilo.Session.Open\"")) + assertTrue(xml.contains("id=\"Kilo.Session.Rename\"")) + assertTrue(xml.contains("id=\"Kilo.Session.Delete\"")) + assertTrue(xml.contains("id=\"Kilo.History.ContextMenu\"")) + assertTrue(xml.contains("ref=\"Kilo.Session.Open\"")) + assertTrue(xml.contains("ref=\"Kilo.Session.Rename\"")) + assertTrue(xml.contains("ref=\"Kilo.Session.Delete\"")) + } + + // ------ Helpers ------ + + private fun event( + action: com.intellij.openapi.actionSystem.AnAction, + manager: SessionManager?, + selection: HistorySelection, + ctrl: HistoryController, + ): AnActionEvent { + val presentation = Presentation().apply { copyFrom(action.templatePresentation) } + val context = DataContext { id -> + when { + CommonDataKeys.PROJECT.`is`(id) -> project + SessionManager.KEY.`is`(id) -> manager + HistoryDataKeys.SELECTION.`is`(id) -> selection + HistoryDataKeys.CONTROLLER.`is`(id) -> ctrl + else -> null + } + } + return AnActionEvent.createFromDataContext("", presentation, context) + } + + private fun selection( + source: HistorySource, + local: List, + cloud: List = emptyList(), + ) = HistorySelection(source, local, cloud) + + private fun localItem(id: String, title: String = id) = LocalHistoryItem(sessionDto(id, title)) + + private fun sessionDto(id: String, title: String) = SessionDto( + id = id, + projectID = "prj", + directory = "/test", + title = title, + version = "1", + time = SessionTimeDto(created = 1.0, updated = 2.0), + ) + + private fun cloudItem(id: String, title: String = id) = CloudHistoryItem( + CloudSessionDto( + id = id, + title = title, + createdAt = "2026-01-01T00:00:00Z", + updatedAt = "2026-01-02T00:00:00Z", + version = 1.0, + ) + ) + + private fun flush() = runBlocking { + repeat(10) { + delay(100) + ApplicationManager.getApplication().invokeAndWait { UIUtil.dispatchAllInvocationEvents() } + } + } + + private fun waitFor(done: () -> Boolean) = runBlocking { + withTimeout(5_000) { + while (!done()) { + delay(25) + ApplicationManager.getApplication().invokeAndWait { UIUtil.dispatchAllInvocationEvents() } + } + } + ApplicationManager.getApplication().invokeAndWait { UIUtil.dispatchAllInvocationEvents() } + } + + private class FakeManager : SessionManager { + override fun newSession() {} + override fun showHistory() {} + override fun openSession(ref: SessionRef) {} + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloSessionServiceTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloSessionServiceTest.kt new file mode 100644 index 00000000000..3a6ae7a3d99 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloSessionServiceTest.kt @@ -0,0 +1,100 @@ +package ai.kilocode.client.app + +import ai.kilocode.client.testing.FakeSessionRpcApi +import ai.kilocode.rpc.dto.SessionDto +import ai.kilocode.rpc.dto.SessionTimeDto +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext + +@Suppress("UnstableApiUsage") +class KiloSessionServiceTest : BasePlatformTestCase() { + private lateinit var scope: CoroutineScope + private lateinit var rpc: FakeSessionRpcApi + private lateinit var service: KiloSessionService + + override fun setUp() { + super.setUp() + scope = CoroutineScope(SupervisorJob()) + rpc = FakeSessionRpcApi() + service = KiloSessionService(project, scope, rpc) + } + + override fun tearDown() { + try { + scope.cancel() + } finally { + super.tearDown() + } + } + + fun `test rename replaces cached session in sessions value`() = runBlocking(Dispatchers.Default) { + rpc.listed += session("ses_1", "Original") + rpc.listed += session("ses_2", "Other") + service.list("/test") + + assertEquals(2, service.sessions.value.size) + assertEquals("Original", service.sessions.value.find { it.id == "ses_1" }!!.title) + + service.renameSession("ses_1", "/test", "Renamed") + + assertEquals(2, service.sessions.value.size) + assertEquals("Renamed", service.sessions.value.find { it.id == "ses_1" }!!.title) + assertEquals("Other", service.sessions.value.find { it.id == "ses_2" }!!.title) + } + + fun `test rename of unknown id does not insert new item`() = runBlocking(Dispatchers.Default) { + rpc.listed += session("ses_1", "Original") + service.list("/test") + + assertEquals(1, service.sessions.value.size) + + service.renameSession("ses_unknown", "/test", "Should Not Insert") + + // Size remains 1 — no unexpected insert + assertEquals(1, service.sessions.value.size) + assertEquals("ses_1", service.sessions.value[0].id) + } + + fun `test rename failure propagates exception without mutating cache`() = runBlocking(Dispatchers.Default) { + rpc.listed += session("ses_1", "Original") + service.list("/test") + + val before = service.sessions.value.toList() + rpc.renameThrows = RuntimeException("server error") + + var threw = false + try { + service.renameSession("ses_1", "/test", "Renamed") + } catch (_: RuntimeException) { + threw = true + } + + assertTrue(threw) + assertEquals(before.map { it.id to it.title }, service.sessions.value.map { it.id to it.title }) + } + + fun `test list populates sessions value`() = runBlocking(Dispatchers.Default) { + rpc.listed += session("ses_1", "One") + rpc.listed += session("ses_2", "Two") + + service.list("/test") + + assertEquals(2, service.sessions.value.size) + assertTrue(service.sessions.value.any { it.id == "ses_1" }) + assertTrue(service.sessions.value.any { it.id == "ses_2" }) + } + + private fun session(id: String, title: String) = SessionDto( + id = id, + projectID = "prj", + directory = "/test", + title = title, + version = "1", + time = SessionTimeDto(created = 1.0, updated = 2.0), + ) +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/history/HistoryControllerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/history/HistoryControllerTest.kt index 250877766f5..213f6cd7440 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/history/HistoryControllerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/history/HistoryControllerTest.kt @@ -4,6 +4,7 @@ import ai.kilocode.client.app.KiloSessionService import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.app.Workspace import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.SessionManager import ai.kilocode.client.session.SessionRef import ai.kilocode.client.testing.FakeSessionRpcApi import ai.kilocode.client.testing.FakeWorkspaceRpcApi @@ -197,7 +198,7 @@ class HistoryControllerTest : BasePlatformTestCase() { flush() val local = panel.defaultFocusedComponent - assertFalse(panel.listFocusable()) + assertTrue(panel.listFocusable()) assertEquals(-1, panel.selectedIndex()) key(local, KeyEvent.VK_DOWN) assertEquals(0, panel.selectedIndex()) @@ -211,7 +212,7 @@ class HistoryControllerTest : BasePlatformTestCase() { val cloud = panel.defaultFocusedComponent assertNotSame(local, cloud) - assertFalse(panel.listFocusable()) + assertTrue(panel.listFocusable()) assertEquals(-1, panel.selectedIndex()) key(cloud, KeyEvent.VK_DOWN) assertEquals(0, panel.selectedIndex()) @@ -297,17 +298,237 @@ class HistoryControllerTest : BasePlatformTestCase() { assertEquals(KiloBundle.message("history.time.hours", 10), HistoryTime.relative(offset, now.toEpochMilli())) } - fun `test local renderer exposes delete and cloud renderer hides it`() { - rpc.listed += session("ses_1", "Local") - rpc.cloud += cloud("cloud_1", "Cloud") + fun `test list is focusable and uses multiple interval selection`() { val panel = HistoryPanel(parent, controller()) flush() - assertTrue(panel.deleteVisible(0)) + assertTrue(panel.listFocusable()) + assertEquals(javax.swing.ListSelectionModel.MULTIPLE_INTERVAL_SELECTION, panel.listSelectionMode()) + } + + fun `test load more button is focusable`() { + rpc.cloud += cloud("cloud_1", "Cloud") + rpc.cloudCursor = "next" + val controller = controller() + val panel = HistoryPanel(parent, controller) + flush() + + assertTrue(panel.loadMoreFocusable()) + } + + fun `test rename updates local item title`() { + rpc.listed += session("ses_1", "Original") + val controller = controller() + flush() + + controller.reloadLocal() + flush() + + val item = controller.local.items[0] + controller.rename(item, "Renamed") + flush() + + assertEquals("Renamed", controller.local.items[0].title) + assertEquals(listOf(Triple("ses_1", "/test", "Renamed")), rpc.renames) + } + + fun `test data context exposes selection and controller`() { + rpc.listed += session("ses_1", "Alpha") + val controller = controller() + val panel = HistoryPanel(parent, controller) + flush() + + panel.select(0) + + val sel = panel.getData(HistoryDataKeys.SELECTION.name) as? HistorySelection + assertNotNull(sel) + assertEquals(1, sel!!.selectedLocal.size) + assertEquals("ses_1", sel.selectedLocal[0].id) + + val ctrl = panel.getData(HistoryDataKeys.CONTROLLER.name) + assertSame(controller, ctrl) + } + + // ------ Multi-selection and cloud selection data context ------ + + fun `test data context exposes local multi-selection`() { + rpc.listed += session("ses_1", "Alpha") + rpc.listed += session("ses_2", "Beta") + val controller = controller() + val panel = HistoryPanel(parent, controller) + flush() + + panel.selectIndices(0, 1) + + val sel = panel.getData(HistoryDataKeys.SELECTION.name) as? HistorySelection + assertNotNull(sel) + assertEquals(HistorySource.LOCAL, sel!!.source) + assertEquals(2, sel.selectedLocal.size) + assertTrue(sel.selectedLocal.map { it.id }.containsAll(listOf("ses_1", "ses_2"))) + } + + fun `test data context exposes cloud selection`() { + rpc.cloud += cloud("cloud_1", "Cloud One") + val controller = controller() + val panel = HistoryPanel(parent, controller) + flush() panel.clickCloud() flush() - assertFalse(panel.cloudDeleteVisible(0)) + panel.select(0) + + val sel = panel.getData(HistoryDataKeys.SELECTION.name) as? HistorySelection + assertNotNull(sel) + assertEquals(HistorySource.CLOUD, sel!!.source) + assertTrue(sel.selectedLocal.isEmpty()) + assertEquals(1, sel.cloudItems.size) + assertEquals("cloud_1", sel.cloudItems[0].id) + } + + fun `test data context exposes session manager`() { + val manager = FakeManager() + val controller = controller() + val panel = HistoryPanel(parent, controller, manager = manager) + flush() + + assertSame(manager, panel.getData(SessionManager.KEY.name)) + } + + fun `test data context returns null for absent session manager`() { + val controller = controller() + val panel = HistoryPanel(parent, controller) + flush() + + assertNull(panel.getData(SessionManager.KEY.name)) + } + + fun `test local list uses multiple interval selection mode`() { + val panel = HistoryPanel(parent, controller()) + flush() + + assertEquals(javax.swing.ListSelectionModel.MULTIPLE_INTERVAL_SELECTION, panel.listSelectionMode()) + } + + fun `test cloud list uses single selection mode`() { + val panel = HistoryPanel(parent, controller()) + flush() + + panel.clickCloud() + flush() + + assertEquals(javax.swing.ListSelectionModel.SINGLE_SELECTION, panel.listSelectionMode()) + } + + // ------ Rename failure and directory selection ------ + + fun `test rename failure keeps original title and sets error`() { + rpc.listed += session("ses_1", "Original") + val controller = controller() + controller.reloadLocal() + flush() + + rpc.renameThrows = IllegalStateException("rename failed") + val item = controller.local.items[0] + controller.rename(item, "Renamed") + flush() + + assertEquals("Original", controller.local.items[0].title) + assertNotNull(controller.local.error) + } + + fun `test rename uses item directory when present`() { + val dto = session("ses_1", "Original").copy(directory = "/worktree/path") + rpc.listed += dto + val controller = controller() + controller.reloadLocal() + flush() + + val item = controller.local.items[0] + assertEquals("/worktree/path", item.directory) + + controller.rename(item, "Renamed") + flush() + + assertEquals(listOf(Triple("ses_1", "/worktree/path", "Renamed")), rpc.renames) + } + + fun `test rename falls back to workspace directory when item directory is workspace`() { + // When session.directory matches workspace directory (not a worktree override) + rpc.listed += session("ses_1", "Original") + val controller = controller() + controller.reloadLocal() + flush() + + val item = controller.local.items[0] + controller.rename(item, "Renamed") + flush() + + assertEquals(listOf(Triple("ses_1", "/test", "Renamed")), rpc.renames) + } + + // ------ HistoryModel update/sorting ------ + + fun `test model update re-sorts items by updated time`() { + val now = java.time.Instant.now() + rpc.listed += session("ses_1", "Alpha", now.toEpochMilli().toDouble()) + rpc.listed += session("ses_2", "Beta", now.minusSeconds(100).toEpochMilli().toDouble()) + val controller = controller() + controller.reloadLocal() + flush() + + // ses_1 is newer so comes first + assertEquals("ses_1", controller.local.items[0].id) + + // Update ses_2 to be newer + val updated = session("ses_2", "Beta Updated", now.plusSeconds(100).toEpochMilli().toDouble()) + controller.local.update(LocalHistoryItem(updated)) + + // Now ses_2 should come first + assertEquals("ses_2", controller.local.items[0].id) + assertEquals("Beta Updated", controller.local.items[0].title) + } + + fun `test model update with unknown id leaves model unchanged`() { + rpc.listed += session("ses_1", "Alpha") + val controller = controller() + controller.reloadLocal() + flush() + + val before = controller.local.items.toList() + val unknown = session("unknown_id", "Unknown") + controller.local.update(LocalHistoryItem(unknown)) + + assertEquals(before.map { it.id }, controller.local.items.map { it.id }) + } + + fun `test model update removes renamed item from active filter`() { + rpc.listed += session("ses_1", "Alpha") + rpc.listed += session("ses_2", "Beta") + val controller = controller() + controller.reloadLocal() + flush() + + controller.local.setFilter("alpha") + assertEquals(listOf("ses_1"), controller.local.visibleItems.map { it.id }) + + controller.local.update(LocalHistoryItem(session("ses_1", "Gamma"))) + + assertTrue(controller.local.visibleItems.isEmpty()) + } + + fun `test model update adds renamed item to active filter`() { + rpc.listed += session("ses_1", "Alpha") + rpc.listed += session("ses_2", "Beta") + val controller = controller() + controller.reloadLocal() + flush() + + controller.local.setFilter("gamma") + assertTrue(controller.local.visibleItems.isEmpty()) + + controller.local.update(LocalHistoryItem(session("ses_2", "Gamma"))) + + assertEquals(listOf("ses_2"), controller.local.visibleItems.map { it.id }) } private fun controller() = HistoryController(sessions, workspace, scope) @@ -320,6 +541,12 @@ class HistoryControllerTest : BasePlatformTestCase() { opened.add(id) }) + private class FakeManager : SessionManager { + override fun newSession() {} + override fun showHistory() {} + override fun openSession(ref: SessionRef) {} + } + private fun collect(controller: HistoryController): MutableList { val events = mutableListOf() val listener = object : ListDataListener { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt index dd7c8537930..ca5d29db1cc 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt @@ -86,6 +86,9 @@ class FakeSessionRpcApi : KiloSessionRpcApi { val questionReplies = mutableListOf>() val questionRejects = mutableListOf>() val deletes = mutableListOf>() + var deleteGate: CompletableDeferred? = null + val renames = mutableListOf>() + var renameThrows: Exception? = null val lists = mutableListOf() val recentCalls = mutableListOf>() val cloudCalls = mutableListOf() @@ -127,10 +130,23 @@ class FakeSessionRpcApi : KiloSessionRpcApi { override suspend fun delete(id: String, directory: String) { assertNotEdt("delete") + deleteGate?.await() deletes.add(id to directory) listed.removeAll { it.id == id } } + override suspend fun rename(id: String, directory: String, title: String): SessionDto { + assertNotEdt("rename") + renameThrows?.let { throw it } + renames.add(Triple(id, directory, title)) + val updated = listed.indexOfFirst { it.id == id } + if (updated >= 0) { + listed[updated] = listed[updated].copy(title = title) + return listed[updated] + } + return session.copy(id = id, title = title) + } + override suspend fun cloudSessions(directory: String, cursor: String?, limit: Int, gitUrl: String?): CloudSessionListDto { assertNotEdt("cloudSessions") cloudCalls.add(CloudCall(directory, cursor, limit, gitUrl)) diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt index c467af110d1..25c8a63c3ab 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt @@ -51,6 +51,9 @@ interface KiloSessionRpcApi : RemoteApi { /** Delete a session. */ suspend fun delete(id: String, directory: String) + /** Rename a session. */ + suspend fun rename(id: String, directory: String, title: String): SessionDto + /** List cloud-backed sessions. */ suspend fun cloudSessions(directory: String, cursor: String?, limit: Int, gitUrl: String?): CloudSessionListDto