diff --git a/.changeset/semantic-jetbrains-timeline.md b/.changeset/semantic-jetbrains-timeline.md new file mode 100644 index 00000000000..4ae0ffed6a3 --- /dev/null +++ b/.changeset/semantic-jetbrains-timeline.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Improve JetBrains session timeline colors to follow semantic theme keys. 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 da11b8c78c1..1df64adacc1 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 @@ -121,13 +121,13 @@ object SessionUiStyle { /** Colors for timeline/activity indicators in the session header. */ object Timeline { - val READ: Color = JBColor(Color(0x37, 0x94, 0xff), Color(0x37, 0x94, 0xff)) - val WRITE: Color = JBColor(Color(0x00, 0x7f, 0xd4), Color(0x00, 0x7f, 0xd4)) - val TOOL: Color = JBColor(Color(0x00, 0x7a, 0xcc), Color(0x00, 0x7a, 0xcc)) + val READ: Color = JBColor.namedColor("Kilo.Session.Timeline.Read", Color(0x37, 0x94, 0xff)) + val WRITE: Color = JBColor.namedColor("Kilo.Session.Timeline.Write", Color(0x00, 0x7f, 0xd4)) + val TOOL: Color = JBColor.namedColor("Kilo.Session.Timeline.Tool", Color(0x00, 0x7a, 0xcc)) val SUCCESS: Color = JBColor.namedColor("Label.successForeground", UIUtil.getLabelSuccessForeground()) - val ERROR: Color = JBColor(Color(0xf4, 0x87, 0x71), Color(0xf4, 0x87, 0x71)) - val TEXT: Color = JBColor(Color(0x9d, 0x9d, 0x9d), Color(0x9d, 0x9d, 0x9d)) - val STEP: Color = JBColor(Color(0x4d, 0x4d, 0x4d), Color(0x4d, 0x4d, 0x4d)) + val ERROR: Color = JBColor.namedColor("Kilo.Session.Timeline.Error", UIUtil.getErrorForeground()) + val TEXT: Color = JBColor.namedColor("Kilo.Session.Timeline.Text", UIUtil.getContextHelpForeground()) + val STEP: Color = JBColor.namedColor("Kilo.Session.Timeline.Step", JBColor.border()) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt index b3013d9b21f..439c33afd8d 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt @@ -23,6 +23,8 @@ import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import ai.kilocode.rpc.dto.MessageDto +import ai.kilocode.rpc.dto.MessageTimeDto import ai.kilocode.rpc.dto.QuestionInfoDto import ai.kilocode.rpc.dto.QuestionRequestDto import ai.kilocode.rpc.dto.SessionDto @@ -36,6 +38,7 @@ import com.intellij.testFramework.fixtures.BasePlatformTestCase import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableSharedFlow import javax.swing.JLabel import javax.swing.JComponent import javax.swing.JPanel @@ -238,6 +241,29 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { assertEquals(listOf("/test" to "ses_1", "/test" to "ses_2", "/test" to "ses_1"), created) } + fun `test queued hidden transcript events are discarded after timeout disposal`() { + useShortInactiveDisposeTimeout() + val flow = MutableSharedFlow(extraBufferCapacity = 16) + rpc.eventFlow = { _, _ -> flow } + val manager = manager() + + manager.openSession(session("ses_1")) + val first = active(manager) + settle() + manager.openSession(session("ses_2")) + kotlinx.coroutines.runBlocking { + flow.emit(ChatEventDto.MessageUpdated("ses_1", msg("msg_hidden", "ses_1", "assistant"))) + flow.emit(ChatEventDto.PartDelta("ses_1", "msg_hidden", "txt_hidden", "text", "stale")) + } + settle() + manager.openSession(session("ses_1")) + val second = active(manager) + settle() + + assertNotSame(first, second) + assertTrue(empty(second)) + } + fun `test pending session is retained for history overlays before timeout`() { useLongInactiveDisposeTimeout() val history = JLabel("History") @@ -623,6 +649,14 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { private fun active(manager: SessionSidePanelManager) = manager.component.getComponent(0) as JPanel + private fun empty(panel: JPanel): Boolean { + var empty = false + com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait { + empty = panel.controller().model.isEmpty() + } + return empty + } + private fun useShortInactiveDisposeTimeout() = setInactiveDisposeTimeout(10) private fun useLongInactiveDisposeTimeout() = setInactiveDisposeTimeout(60_000) @@ -682,6 +716,13 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { time = SessionTimeDto(created = 1.0, updated = 2.0), ) + private fun msg(id: String, session: String, role: String) = MessageDto( + id = id, + sessionID = session, + role = role, + time = MessageTimeDto(created = 1.0), + ) + private fun cloud(id: String) = CloudSessionDto( id = id, title = "Cloud $id", diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt index a691ddea409..6e5d17d406c 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt @@ -16,10 +16,12 @@ import ai.kilocode.rpc.dto.TodoDto import ai.kilocode.rpc.dto.TodoViewDto import ai.kilocode.rpc.dto.TokensDto import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.util.Disposer -import com.intellij.testFramework.UsefulTestCase +import com.intellij.testFramework.fixtures.BasePlatformTestCase -class SessionModelTest : UsefulTestCase() { +@Suppress("UnstableApiUsage") +class SessionModelTest : BasePlatformTestCase() { private lateinit var model: SessionModel private lateinit var parent: Disposable @@ -50,6 +52,15 @@ class SessionModelTest : UsefulTestCase() { assertEquals(SessionState.Idle, model.state) } + fun `test model mutation works through EDT`() { + // The test fixture does not consistently throw for @RequiresEdt when called + // from a pooled thread, so keep this as a behavioral EDT contract check. + edt { model.addMessage(msg("on_edt", "assistant")) } + + assertNotNull(edt { model.message("on_edt") }) + assertTrue(events.any { it is SessionModelEvent.MessageAdded && it.info.info.id == "on_edt" }) + } + fun `test isReady requires app and workspace readiness`() { model.app = KiloAppStateDto(KiloAppStatusDto.READY) assertFalse(model.isReady()) @@ -883,4 +894,11 @@ class SessionModelTest : UsefulTestCase() { private fun assertModel(expected: String) { assertEquals(expected.trimIndent().trim(), model.toString().trim()) } + + private fun edt(block: () -> T): T { + var result: T? = null + ApplicationManager.getApplication().invokeAndWait { result = block() } + @Suppress("UNCHECKED_CAST") + return result as T + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt index 100d1bb12ab..599f2ebed09 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt @@ -6,6 +6,7 @@ import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.model.ToolKind import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.controller.SessionControllerTestBase import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.MessageDto @@ -21,6 +22,7 @@ import java.awt.Color import java.awt.Point import java.awt.event.MouseEvent import java.awt.event.MouseWheelEvent +import javax.swing.UIManager class SessionHeaderPanelTest : SessionControllerTestBase() { @@ -120,7 +122,20 @@ class SessionHeaderPanelTest : SessionControllerTestBase() { emit(ChatEventDto.TodoUpdated("ses_test", listOf(TodoDto("Done", "completed", "high")))) assertEquals("All 1 todos complete", panel.todoText()) - assertEquals(ai.kilocode.client.session.ui.style.SessionUiStyle.Timeline.SUCCESS, panel.foregrounds()[3]) + assertEquals(SessionUiStyle.Timeline.SUCCESS, panel.foregrounds()[3]) + } + + fun `test timeline colors honor semantic named color keys`() { + val old = UIManager.getColor("Kilo.Session.Timeline.Read") + val color = Color(12, 34, 56) + + try { + UIManager.put("Kilo.Session.Timeline.Read", color) + + assertEquals(color.rgb, SessionUiStyle.Timeline.READ.rgb) + } finally { + UIManager.put("Kilo.Session.Timeline.Read", old) + } } fun `test retained labels update on later header event`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt index aba4646d732..6efd3d240c1 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt @@ -706,6 +706,18 @@ class QuestionViewTest : BasePlatformTestCase() { assertNull("Empty custom editor should be removed after selecting a normal option", findAll(view).firstOrNull { it.parent != null }) } + fun `test empty custom editor is detached after forcing underlying editor creation`() { + view.show(customSingleQuestion("q_custom_editor_release")) + findAll(view).first { it.actionCommand == "" }.doClick() + val field = findAll(view).first() + val editor = field.getEditor(true) + assertSame(editor, field.getEditor(false)) + + option(view, "Minimal").doClick() + + assertNull(SwingUtilities.getAncestorOfClass(QuestionView::class.java, field)) + } + fun `test focusing retained custom editor reselects custom response`() { view.show(customSingleQuestion("q_custom_focus")) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TextViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TextViewTest.kt index 37f37965485..64c6e8db68f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TextViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TextViewTest.kt @@ -3,6 +3,8 @@ package ai.kilocode.client.session.views import ai.kilocode.client.session.model.Text import ai.kilocode.client.session.ui.style.SessionEditorStyle import com.intellij.testFramework.fixtures.BasePlatformTestCase +import javax.swing.JComponent +import javax.swing.RepaintManager /** * Tests for [TextView]. @@ -63,6 +65,24 @@ class TextViewTest : BasePlatformTestCase() { assertEquals("first second", view.markdown()) } + fun `test appendDelta empty string does not repaint or change markdown`() { + val view = TextView(Text("p1").also { it.content.append("keep") }) + val repaint = TrackingRepaintManager(view) + val old = RepaintManager.currentManager(view) + + try { + RepaintManager.setCurrentManager(repaint) + + view.appendDelta("") + + assertEquals("keep", view.markdown()) + assertEquals(0, repaint.dirty) + assertEquals(0, repaint.invalid) + } finally { + RepaintManager.setCurrentManager(old) + } + } + // ---- contentId ------ fun `test contentId matches Text id`() { @@ -123,4 +143,19 @@ class TextViewTest : BasePlatformTestCase() { assertEquals(listOf("https://kilocode.ai/docs"), urls) } + + private class TrackingRepaintManager(private val watched: JComponent) : RepaintManager() { + var dirty = 0 + var invalid = 0 + + override fun addDirtyRegion(c: JComponent, x: Int, y: Int, w: Int, h: Int) { + if (c === watched) dirty++ + super.addDirtyRegion(c, x, y, w, h) + } + + override fun addInvalidComponent(invalidComponent: JComponent) { + if (invalidComponent === watched) invalid++ + super.addInvalidComponent(invalidComponent) + } + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt index 8887e216639..9010598a655 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt @@ -11,6 +11,9 @@ import ai.kilocode.rpc.dto.MessageDto import ai.kilocode.rpc.dto.MessageTimeDto import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.util.ui.JBUI +import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.RepaintManager /** * Tests for [TurnView] and [MessageView]. @@ -191,6 +194,25 @@ class TurnViewTest : BasePlatformTestCase() { mv.appendDelta("unknown", "delta") } + fun `test appendDelta for unknown part id does not repaint message or parent`() { + val parent = JPanel() + val mv = MessageView(msg("a1", "assistant"), openFile) + parent.add(mv) + val repaint = TrackingRepaintManager(setOf(parent, mv)) + val old = RepaintManager.currentManager(parent) + + try { + RepaintManager.setCurrentManager(repaint) + + assertFalse(mv.appendDelta("unknown", "delta")) + + assertTrue(repaint.dirty.isEmpty()) + assertTrue(repaint.invalid.isEmpty()) + } finally { + RepaintManager.setCurrentManager(old) + } + } + fun `test MessageView pre-populates parts from Message on creation`() { val message = msg("a1", "assistant") val text = ai.kilocode.client.session.model.Text("p1").also { it.content.append("preloaded") } @@ -240,4 +262,19 @@ class TurnViewTest : BasePlatformTestCase() { private fun msg(id: String, role: String): Message = Message(MessageDto(id = id, sessionID = "ses", role = role, time = MessageTimeDto(0.0))) + + private class TrackingRepaintManager(private val watched: Set) : RepaintManager() { + val dirty = mutableListOf() + val invalid = mutableListOf() + + override fun addDirtyRegion(c: JComponent, x: Int, y: Int, w: Int, h: Int) { + if (c in watched) dirty.add(c) + super.addDirtyRegion(c, x, y, w, h) + } + + override fun addInvalidComponent(invalidComponent: JComponent) { + if (invalidComponent in watched) invalid.add(invalidComponent) + super.addInvalidComponent(invalidComponent) + } + } } diff --git a/specs/jetbrains-session-ui-findings-todo.md b/specs/jetbrains-session-ui-findings-todo.md index bba8971a249..47c1240cecb 100644 --- a/specs/jetbrains-session-ui-findings-todo.md +++ b/specs/jetbrains-session-ui-findings-todo.md @@ -8,7 +8,7 @@ Last status check: 2026-06-02. - Implemented high-priority work: streaming markdown no longer rebuilds the full rendered tree on normal deltas, and hidden cached session UIs are disposed after a configurable timeout while `SessionUpdateQueue` uses a shared coroutine ticker instead of per-session scheduler threads. - Remaining high-priority work: none. `SessionController` subscription-state mutation is now EDT-confined while RPC event collection remains on background coroutines. -- Remaining non-high-priority work: repaint/revalidate cleanup, lazy collapsed bodies, question editor disposal, EDT annotations/assertions, style callback disposal guards, `SessionUpdateQueue` Swing listener EDT confinement, question body retention, semantic timeline colors, and additional retained Swing regression tests. +- Remaining findings: none. Semantic timeline colors and retained Swing regression coverage are complete. ## High Priority @@ -82,17 +82,18 @@ Last status check: 2026-06-02. ## Low Priority -- [ ] Replace hardcoded timeline runtime colors with semantic named colors +- [x] Replace hardcoded timeline runtime colors with semantic named colors - Severity: Low/Medium - Files: `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt` - Issue: timeline color tokens use numeric `JBColor(Color(...), Color(...))`; AGENTS.md prefers theme-derived or `JBColor.namedColor(...)` semantic keys. - - Plan direction: Introduce named color keys with appropriate fallbacks or map to existing platform theme colors. + - Implemented: timeline tokens now use `JBColor.namedColor(...)` keys under `Kilo.Session.Timeline.*`, with platform semantic fallbacks for success/error/text/step and centralized blue fallbacks for read/write/tool. + - Tests/release note: `SessionHeaderPanelTest` verifies timeline colors honor semantic UIManager named-color overrides; `.changeset/semantic-jetbrains-timeline.md` documents the user-facing theming improvement. -- [ ] Add focused regression tests for retained Swing behavior +- [x] Add focused regression tests for retained Swing behavior - Severity: Low - Files: `packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/**` - Issue: Several risks need tests: no parent refresh on no-op deltas, lazy body creation, editor disposal, hidden-session queue bounds, and EDT assertions. - - Plan direction: Extend existing session UI/controller tests using real IntelliJ EDT fixtures, not mocks. + - Implemented: added no-op repaint coverage for unknown message deltas and empty text deltas, custom question editor detachment coverage after forcing lazy editor creation, hidden queued transcript discard coverage after timeout disposal, and an EDT assertion regression for `SessionModel` mutation. ## Confirmed OK