fix(jetbrains): use semantic session timeline colors

This commit is contained in:
kirillk
2026-06-02 22:59:03 -04:00
parent 8c06a3b784
commit 87b0cf15dd
9 changed files with 178 additions and 14 deletions
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Improve JetBrains session timeline colors to follow semantic theme keys.
@@ -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())
}
}
@@ -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<ChatEventDto>(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",
@@ -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 <T> edt(block: () -> T): T {
var result: T? = null
ApplicationManager.getApplication().invokeAndWait { result = block() }
@Suppress("UNCHECKED_CAST")
return result as T
}
}
@@ -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`() {
@@ -706,6 +706,18 @@ class QuestionViewTest : BasePlatformTestCase() {
assertNull("Empty custom editor should be removed after selecting a normal option", findAll<EditorTextField>(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<JBRadioButton>(view).first { it.actionCommand == "" }.doClick()
val field = findAll<EditorTextField>(view).first()
val editor = field.getEditor(true)
assertSame(editor, field.getEditor(false))
option<JBRadioButton>(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"))
@@ -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)
}
}
}
@@ -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<JComponent>) : RepaintManager() {
val dirty = mutableListOf<JComponent>()
val invalid = mutableListOf<JComponent>()
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)
}
}
}
+6 -5
View File
@@ -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