refactor(jetbrains): isolate session update and layout helpers

Move controller queue types into a dedicated update package and extract the generic layered root so SessionUi can stay focused on composition. Rename the message list panel to match its role and keep the queue condenser with the update flow.
This commit is contained in:
kirillk
2026-04-24 14:13:05 -04:00
parent 16c93d2d1a
commit bb5ddee221
24 changed files with 235 additions and 376 deletions
@@ -34,7 +34,7 @@ import kotlinx.coroutines.launch
* Project-level frontend service for session management.
*
* Stateless with respect to "active session" — callers pass explicit
* session IDs. [ai.kilocode.client.session.SessionController] owns the
* session IDs. [ai.kilocode.client.session.update.SessionController] owns the
* active session concept.
*/
@Service(Service.Level.PROJECT)
@@ -11,7 +11,11 @@ import ai.kilocode.client.session.ui.LabelPicker
import ai.kilocode.client.session.ui.PermissionPanel
import ai.kilocode.client.session.ui.PromptPanel
import ai.kilocode.client.session.ui.QuestionPanel
import ai.kilocode.client.session.ui.SessionPanel
import ai.kilocode.client.session.ui.SessionRootPanel
import ai.kilocode.client.session.ui.SessionMessageListPanel
import ai.kilocode.client.session.update.EVENT_FLUSH_MS
import ai.kilocode.client.session.update.SessionController
import ai.kilocode.client.session.update.SessionControllerEvent
import com.intellij.openapi.Disposable
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.registry.Registry
@@ -23,7 +27,6 @@ import kotlinx.coroutines.CoroutineScope
import java.awt.BorderLayout
import java.awt.CardLayout
import javax.swing.BoxLayout
import javax.swing.JLayeredPane
import javax.swing.JPanel
import javax.swing.SwingUtilities
@@ -31,11 +34,11 @@ import javax.swing.SwingUtilities
* Top-level session UI — a thin composition root.
*
* Responsibilities:
* - Creates and wires [SessionController], [SessionPanel], [EmptySessionPanel],
* - Creates and wires [ai.kilocode.client.session.update.SessionController], [SessionMessageListPanel], [EmptySessionPanel],
* [ConnectionPanel],
* [PromptPanel], [QuestionPanel], [PermissionPanel].
* - Switches between the status (loading) card and the transcript card via
* [SessionControllerEvent.ViewChanged].
* [ai.kilocode.client.session.update.SessionControllerEvent.ViewChanged].
* - Keeps [ConnectionPanel] on a transparent overlay layer directly above the
* prompt.
* - Delegates all transcript and dock updates to the panels themselves via
@@ -65,9 +68,9 @@ class SessionUi(
?: EVENT_FLUSH_MS
private val controller = SessionController(
this, null, sessions, workspace, app, cs, this,
flushMs = flushMs,
condense = Registry.`is`("kilo.session.condense", true),
this, null, sessions, workspace, app, cs, this,
flushMs = flushMs,
condense = Registry.`is`("kilo.session.condense", true),
)
// ------ card switch ------
@@ -81,7 +84,7 @@ class SessionUi(
// ------ transcript ------
private val transcript = SessionPanel(controller.model, this)
private val transcript = SessionMessageListPanel(controller.model, this)
private val scroll = JBScrollPane(transcript).apply {
border = JBUI.Borders.empty()
@@ -103,34 +106,8 @@ class SessionUi(
onAbort = { controller.abort() },
)
private val content = JPanel(BorderLayout())
private val overlay = object : JPanel(null) {
override fun contains(x: Int, y: Int): Boolean {
for (child in components) {
if (child.isVisible && child.bounds.contains(x, y)) return true
}
return false
}
}.apply {
isOpaque = false
}
private val root: JLayeredPane = object : JLayeredPane() {
override fun doLayout() {
content.setBounds(0, 0, width, height)
overlay.setBounds(0, 0, width, height)
content.doLayout()
prompt.parent?.doLayout()
val box = SwingUtilities.convertRectangle(prompt.parent, prompt.bounds, overlay)
val h = connection.preferredSize.height
connection.setBounds(box.x, maxOf(0, box.y - h), box.width, h)
connection.doLayout()
}
override fun getPreferredSize() = content.preferredSize
private val root = SessionRootPanel().apply {
content.layout = BorderLayout()
}
init {
@@ -149,15 +126,13 @@ class SessionUi(
center.add(scroll, MESSAGES)
cards.show(center, STATUS)
content.add(center, BorderLayout.CENTER)
content.add(south, BorderLayout.SOUTH)
overlay.add(connection)
root.add(content)
root.setLayer(content, JLayeredPane.DEFAULT_LAYER)
root.add(overlay)
root.setLayer(overlay, JLayeredPane.PALETTE_LAYER)
root.content.add(center, BorderLayout.CENTER)
root.content.add(south, BorderLayout.SOUTH)
root.addOverlay(connection) { panel, child ->
val box = SwingUtilities.convertRectangle(prompt.parent, prompt.bounds, panel)
val h = child.preferredSize.height
java.awt.Rectangle(box.x, maxOf(0, box.y - h), box.width, h)
}
add(root, BorderLayout.CENTER)
@@ -256,8 +231,8 @@ class SessionUi(
private fun refresh() {
center.revalidate()
center.repaint()
content.revalidate()
content.repaint()
root.content.revalidate()
root.content.repaint()
root.revalidate()
root.repaint()
}
@@ -15,7 +15,7 @@ import com.intellij.openapi.util.Disposer
/**
* Pure session model — single source of truth for session content and runtime state.
*
* **EDT-only access** — no synchronization. [ai.kilocode.client.session.SessionController] guarantees all
* **EDT-only access** — no synchronization. [ai.kilocode.client.session.update.SessionController] guarantees all
* reads and writes happen on the EDT.
*
* In addition to the flat message list, the model maintains a derived
@@ -1,9 +1,9 @@
package ai.kilocode.client.session.ui
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.SessionController
import ai.kilocode.client.session.SessionControllerEvent
import ai.kilocode.client.session.SessionControllerListener
import ai.kilocode.client.session.update.SessionController
import ai.kilocode.client.session.update.SessionControllerEvent
import ai.kilocode.client.session.update.SessionControllerListener
import ai.kilocode.rpc.dto.KiloAppStatusDto
import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto
import com.intellij.openapi.Disposable
@@ -1,282 +0,0 @@
package ai.kilocode.client.session.ui
import ai.kilocode.client.session.model.SessionModel
import ai.kilocode.client.session.model.SessionModelEvent
import ai.kilocode.client.session.model.SessionState
import ai.kilocode.client.session.model.Compaction
import ai.kilocode.client.session.model.Content
import ai.kilocode.client.session.model.Generic
import ai.kilocode.client.session.model.Message
import ai.kilocode.client.session.model.Reasoning
import ai.kilocode.client.session.model.Text
import ai.kilocode.client.session.model.Tool
import ai.kilocode.client.session.model.ToolExecState
import com.intellij.openapi.Disposable
import com.intellij.ui.AnimatedIcon
import com.intellij.ui.JBColor
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import java.awt.BorderLayout
import java.awt.FlowLayout
import javax.swing.BoxLayout
import javax.swing.JPanel
import javax.swing.JTextArea
import javax.swing.border.MatteBorder
/**
* Scrollable panel displaying session messages aligned to the top,
* with an optional animated status indicator at the bottom.
*
* Passive view — all rendering is driven by [SessionModelEvent]s
* from the [SessionModel]. No public mutation methods.
*/
class MessageListUi(
parent: Disposable,
private val model: SessionModel,
) : JPanel(BorderLayout()) {
private val blocks = LinkedHashMap<String, MessageBlock>()
private var errorLabel: JBLabel? = null
private val inner = JPanel().apply {
layout = BoxLayout(this, BoxLayout.Y_AXIS)
isOpaque = false
border = JBUI.Borders.empty(4, 8)
}
private val label = JBLabel().apply {
foreground = UIUtil.getContextHelpForeground()
}
private val spinner = JPanel(FlowLayout(FlowLayout.LEFT, JBUI.scale(4), 0)).apply {
isOpaque = false
isVisible = false
border = JBUI.Borders.empty(6, 0)
alignmentX = LEFT_ALIGNMENT
add(JBLabel(AnimatedIcon.Default()))
add(label)
}
init {
isOpaque = true
background = UIUtil.getPanelBackground()
inner.add(spinner)
add(inner, BorderLayout.NORTH)
model.addListener(parent) { event ->
when (event) {
is SessionModelEvent.MessageAdded -> onAdded(event.info)
is SessionModelEvent.MessageUpdated -> onAdded(event.info) // refresh/upsert
is SessionModelEvent.MessageRemoved -> onRemoved(event.id)
is SessionModelEvent.ContentAdded -> onContentAdded(event.messageId, event.content)
is SessionModelEvent.ContentUpdated -> onContentUpdated(event.messageId, event.content)
is SessionModelEvent.ContentRemoved -> onContentRemoved(event.messageId, event.contentId)
is SessionModelEvent.ContentDelta -> onContentDelta(event.messageId, event.contentId, event.delta)
is SessionModelEvent.StateChanged -> onState(event.state)
is SessionModelEvent.HistoryLoaded -> onHistory()
is SessionModelEvent.Cleared -> onCleared()
is SessionModelEvent.DiffUpdated,
is SessionModelEvent.TodosUpdated,
is SessionModelEvent.Compacted,
is SessionModelEvent.TurnAdded,
is SessionModelEvent.TurnUpdated,
is SessionModelEvent.TurnRemoved -> Unit
}
}
}
private fun onAdded(info: Message) {
if (blocks.containsKey(info.info.id)) return
val block = MessageBlock(info)
blocks[info.info.id] = block
inner.add(block, inner.componentCount - 1)
refresh()
}
private fun onRemoved(id: String) {
val block = blocks.remove(id) ?: return
inner.remove(block)
refresh()
}
private fun onContentAdded(messageId: String, content: Content) {
blocks[messageId]?.addContent(content)
refresh()
}
private fun onContentUpdated(messageId: String, content: Content) {
blocks[messageId]?.updateContent(content)
refresh()
}
private fun onContentRemoved(messageId: String, contentId: String) {
blocks[messageId]?.removeContent(contentId)
refresh()
}
private fun onContentDelta(messageId: String, contentId: String, delta: String) {
blocks[messageId]?.appendDelta(contentId, delta)
refresh()
}
private fun onState(state: SessionState) {
errorLabel?.let { inner.remove(it); errorLabel = null }
when (state) {
is SessionState.Busy -> {
label.text = state.text
spinner.isVisible = true
}
is SessionState.Error -> {
spinner.isVisible = false
val err = JBLabel(state.message).apply {
foreground = JBColor.RED
font = JBUI.Fonts.label()
border = JBUI.Borders.empty(4, 0)
alignmentX = LEFT_ALIGNMENT
}
errorLabel = err
inner.add(err, inner.componentCount - 1)
}
else -> {
spinner.isVisible = false
}
}
refresh()
}
private fun onHistory() {
clear()
for (entry in model.messages()) {
val block = MessageBlock(entry)
blocks[entry.info.id] = block
inner.add(block, inner.componentCount - 1)
for ((_, content) in entry.parts) block.addContent(content)
}
refresh()
}
private fun onCleared() {
clear()
refresh()
}
private fun clear() {
blocks.clear()
errorLabel = null
inner.removeAll()
inner.add(spinner)
spinner.isVisible = false
}
private fun refresh() {
revalidate()
repaint()
}
}
private class MessageBlock(info: Message) : JPanel() {
private val areas = LinkedHashMap<String, JTextArea>()
private val labels = LinkedHashMap<String, JBLabel>()
init {
layout = BoxLayout(this, BoxLayout.Y_AXIS)
isOpaque = false
alignmentX = LEFT_ALIGNMENT
border = if (info.info.role == "user") {
JBUI.Borders.compound(
MatteBorder(1, 0, 0, 0, JBColor.border()),
JBUI.Borders.empty(8, 0, 4, 0),
)
} else {
JBUI.Borders.empty(4, 0)
}
}
fun addContent(content: Content) {
when (content) {
is Text -> {
val area = createArea()
if (content.content.isNotEmpty()) area.text = content.content.toString()
areas[content.id] = area
add(area)
}
is Reasoning -> {
val area = createArea().apply {
foreground = UIUtil.getContextHelpForeground()
}
if (content.content.isNotEmpty()) area.text = content.content.toString()
areas[content.id] = area
add(area)
}
is Tool -> {
val lbl = createToolLabel(content)
labels[content.id] = lbl
add(lbl)
}
is Compaction -> {
val lbl = JBLabel("Context compacted").apply {
foreground = UIUtil.getContextHelpForeground()
font = JBUI.Fonts.smallFont()
border = JBUI.Borders.empty(4, 0)
alignmentX = LEFT_ALIGNMENT
}
labels[content.id] = lbl
add(lbl)
}
is Generic -> {} // unknown part type — not rendered in this pass
}
revalidate()
}
fun removeContent(contentId: String) {
areas.remove(contentId)?.let { remove(it) }
labels.remove(contentId)?.let { remove(it) }
revalidate()
}
fun updateContent(content: Content) {
when (content) {
is Text -> areas[content.id]?.text = content.content.toString()
is Reasoning -> areas[content.id]?.text = content.content.toString()
is Tool -> labels[content.id]?.text = toolText(content)
is Compaction -> {}
is Generic -> {}
}
revalidate()
}
fun appendDelta(contentId: String, delta: String) {
areas[contentId]?.append(delta)
revalidate()
}
private fun createArea() = JTextArea().apply {
isEditable = false
lineWrap = true
wrapStyleWord = true
isOpaque = false
font = JBUI.Fonts.label()
foreground = UIUtil.getLabelForeground()
border = JBUI.Borders.empty()
alignmentX = LEFT_ALIGNMENT
}
private fun createToolLabel(content: Tool) = JBLabel(toolText(content)).apply {
foreground = UIUtil.getContextHelpForeground()
font = JBUI.Fonts.smallFont()
border = JBUI.Borders.empty(2, 0)
alignmentX = LEFT_ALIGNMENT
}
private fun toolText(content: Tool): String {
val icon = when (content.state) {
ToolExecState.PENDING -> "\u23F3"
ToolExecState.RUNNING -> "\u25B6"
ToolExecState.COMPLETED -> "\u2713"
ToolExecState.ERROR -> "\u2717"
}
return "$icon ${content.title ?: content.name}"
}
}
@@ -1,6 +1,6 @@
package ai.kilocode.client.session.ui
import ai.kilocode.client.session.SessionController
import ai.kilocode.client.session.update.SessionController
import ai.kilocode.client.session.model.Permission
import ai.kilocode.rpc.dto.PermissionReplyDto
import com.intellij.icons.AllIcons
@@ -18,7 +18,7 @@ import java.awt.FlowLayout
* - [SessionState.Busy] → shows an animated spinner and [SessionState.Busy.text]
* - Any other state → hidden
*
* Owned by [SessionPanel], which always re-anchors it as the last child so it
* Owned by [SessionMessageListPanel], which always re-anchors it as the last child so it
* appears below all turn views inside the scroll pane.
*/
class ProgressPanel(
@@ -1,6 +1,6 @@
package ai.kilocode.client.session.ui
import ai.kilocode.client.session.SessionController
import ai.kilocode.client.session.update.SessionController
import ai.kilocode.client.session.model.Question
import ai.kilocode.rpc.dto.QuestionReplyDto
import com.intellij.ui.JBColor
@@ -28,7 +28,7 @@ import com.intellij.util.ui.JBUI
*
* All method calls must happen on the EDT.
*/
class SessionPanel(
class SessionMessageListPanel(
private val model: SessionModel,
parent: Disposable,
) : SessionLayoutPanel() {
@@ -0,0 +1,76 @@
package ai.kilocode.client.session.ui
import java.awt.Dimension
import java.awt.Rectangle
import javax.swing.JComponent
import javax.swing.JLayeredPane
import javax.swing.JPanel
class SessionRootPanel : JLayeredPane() {
val content = JPanel()
val overlay = Overlay()
init {
layout = null
add(content)
setLayer(content, DEFAULT_LAYER)
add(overlay)
setLayer(overlay, PALETTE_LAYER)
}
fun addOverlay(child: JComponent, bounds: (JPanel, JComponent) -> Rectangle) {
overlay.addOverlay(child, bounds)
}
override fun doLayout() {
components
.sortedBy { getLayer(it) }
.forEach { child ->
child.setBounds(0, 0, width, height)
child.doLayout()
}
}
override fun getPreferredSize(): Dimension {
val w = components.maxOfOrNull { it.preferredSize.width } ?: 0
val h = components.maxOfOrNull { it.preferredSize.height } ?: 0
return Dimension(w, h)
}
class Overlay : JPanel(null) {
private val items = linkedMapOf<JComponent, (JPanel, JComponent) -> Rectangle>()
init {
isOpaque = false
}
fun addOverlay(child: JComponent, bounds: (JPanel, JComponent) -> Rectangle) {
items[child] = bounds
add(child)
}
override fun contains(x: Int, y: Int): Boolean {
for (child in components) {
if (child.isVisible && child.bounds.contains(x, y)) return true
}
return false
}
override fun doLayout() {
items.forEach { (child, bounds) ->
child.bounds = bounds(this, child)
child.doLayout()
}
}
override fun getPreferredSize(): Dimension {
val pref = super.getPreferredSize()
val w = maxOf(pref.width, components.maxOfOrNull { it.preferredSize.width } ?: 0)
val h = maxOf(pref.height, components.maxOfOrNull { it.preferredSize.height } ?: 0)
return Dimension(w, h)
}
}
}
@@ -1,4 +1,4 @@
package ai.kilocode.client.session
package ai.kilocode.client.session.update
import ai.kilocode.client.app.KiloAppService
import ai.kilocode.client.app.KiloSessionService
@@ -35,6 +35,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import java.awt.Component
/**
* Session lifecycle orchestrator for a single session.
@@ -49,15 +50,15 @@ import kotlinx.coroutines.launch
* via [SessionControllerEvent] to registered listeners.
*/
class SessionController(
parent: Disposable,
id: String?,
private val sessions: KiloSessionService,
private val workspace: Workspace,
private val app: KiloAppService,
private val cs: CoroutineScope,
comp: java.awt.Component? = null,
private val flushMs: Long = EVENT_FLUSH_MS,
private val condense: Boolean = true,
parent: Disposable,
id: String?,
private val sessions: KiloSessionService,
private val workspace: Workspace,
private val app: KiloAppService,
private val cs: CoroutineScope,
comp: Component? = null,
private val flushMs: Long = EVENT_FLUSH_MS,
private val condense: Boolean = true,
) : Disposable {
companion object {
@@ -73,7 +74,14 @@ class SessionController(
private val listeners = mutableListOf<SessionControllerListener>()
private var sessionId: String? = id
private val directory: String get() = workspace.directory
private val updates = SessionUpdateQueue(parent, comp, flushMs, ::handle, condense, id != null) { sessionId ?: "pending" }
private val updates = SessionUpdateQueue(
parent,
comp,
flushMs,
::handle,
condense,
id != null
) { sessionId ?: "pending" }
private var partType: String? = null
private var tool: String? = null
@@ -1,4 +1,4 @@
package ai.kilocode.client.session
package ai.kilocode.client.session.update
import ai.kilocode.client.session.model.SessionModel
import ai.kilocode.client.session.model.SessionModelEvent
@@ -1,9 +1,9 @@
package ai.kilocode.client.session
package ai.kilocode.client.session.update
import ai.kilocode.rpc.dto.ChatEventDto
/**
* Reduces a batch of queued [ChatEventDto] events before they are flushed to
* Reduces a batch of queued [ai.kilocode.rpc.dto.ChatEventDto] events before they are flushed to
* the model, by merging consecutive same-key snapshot and text-delta events.
*
* ## Algorithm
@@ -1,4 +1,4 @@
package ai.kilocode.client.session
package ai.kilocode.client.session.update
import ai.kilocode.log.ChatLogSummary
import ai.kilocode.log.KiloLog
@@ -1,7 +1,7 @@
package ai.kilocode.client.session
import ai.kilocode.client.session.model.SessionModelEvent
import ai.kilocode.client.session.model.SessionState
import ai.kilocode.client.session.update.SessionControllerEvent
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.SessionStatusDto
import com.intellij.openapi.util.Disposer
@@ -10,6 +10,8 @@ import ai.kilocode.client.testing.FakeWorkspaceRpcApi
import ai.kilocode.client.testing.FakeSessionRpcApi
import ai.kilocode.client.app.KiloWorkspaceService
import ai.kilocode.client.app.Workspace
import ai.kilocode.client.session.update.SessionController
import ai.kilocode.client.session.update.SessionControllerEvent
import ai.kilocode.rpc.dto.AgentDto
import ai.kilocode.rpc.dto.AgentsDto
import ai.kilocode.rpc.dto.ChatEventDto
@@ -36,7 +38,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
/**
* Base class for [SessionController] tests.
* Base class for [ai.kilocode.client.session.update.SessionController] tests.
*
* Provides real IntelliJ Application/EDT/Disposer via [BasePlatformTestCase],
* real frontend services wired to fake RPC backends, and shared helpers.
@@ -128,7 +130,17 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() {
protected fun controller(id: String? = null, flushMs: Long, condense: Boolean): SessionController {
val root = Root()
val m = SessionController(parent, id, sessions, workspace, app, scope, root, flushMs, condense)
val m = SessionController(
parent,
id,
sessions,
workspace,
app,
scope,
root,
flushMs,
condense
)
controllers.add(m)
roots[m] = root
return m
@@ -1,5 +1,6 @@
package ai.kilocode.client.session
import ai.kilocode.client.session.update.SessionQueueCondenser
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.DiffFileDto
import ai.kilocode.rpc.dto.MessageDto
@@ -5,13 +5,12 @@ import ai.kilocode.rpc.dto.PermissionRequestDto
import ai.kilocode.rpc.dto.QuestionInfoDto
import ai.kilocode.rpc.dto.QuestionRequestDto
import ai.kilocode.rpc.dto.SessionStatusDto
import ai.kilocode.rpc.dto.SessionTimeDto
/**
* Tests for pending permission/question recovery after history load.
*
* VS Code rehydrates pending prompts by calling list endpoints after
* reconnect. JetBrains now does the same in [SessionController.recoverPending].
* reconnect. JetBrains now does the same in [ai.kilocode.client.session.update.SessionController.recoverPending].
*/
class SessionRecoveryTest : SessionControllerTestBase() {
@@ -14,6 +14,8 @@ import ai.kilocode.client.session.ui.ConnectionPanel
import ai.kilocode.client.session.ui.PermissionPanel
import ai.kilocode.client.session.ui.PromptPanel
import ai.kilocode.client.session.ui.QuestionPanel
import ai.kilocode.client.session.ui.SessionRootPanel
import ai.kilocode.client.session.update.SessionController
import ai.kilocode.client.testing.FakeAppRpcApi
import ai.kilocode.client.testing.FakeSessionRpcApi
import ai.kilocode.client.testing.FakeWorkspaceRpcApi
@@ -26,7 +28,6 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import javax.swing.JLayeredPane
import javax.swing.JPanel
import javax.swing.SwingUtilities
@Suppress("UnstableApiUsage")
@@ -71,15 +72,13 @@ class SessionUiLayoutTest : BasePlatformTestCase() {
}
fun `test root contains content and overlay layers`() {
val root = find<JLayeredPane>(ui)
val root = find<SessionRootPanel>(ui)
assertEquals(2, root.componentCount)
assertTrue(root.components.all { it is JPanel })
val panels = root.components.map { it as JPanel }
val overlay = panels.first { it.components.any { child -> child is ConnectionPanel } }
val content = panels.first { it !== overlay }
assertEquals(JLayeredPane.DEFAULT_LAYER, root.getLayer(content))
assertEquals(JLayeredPane.PALETTE_LAYER, root.getLayer(overlay))
assertSame(root.content, root.components.first { it === root.content })
assertSame(root.overlay, root.components.first { it === root.overlay })
assertEquals(JLayeredPane.DEFAULT_LAYER, root.getLayer(root.content))
assertEquals(JLayeredPane.PALETTE_LAYER, root.getLayer(root.overlay))
}
fun `test overlay panel matches prompt width and sits above prompt`() {
@@ -138,7 +137,7 @@ class SessionUiLayoutTest : BasePlatformTestCase() {
private fun layout() {
ui.doLayout()
find<JLayeredPane>(ui).doLayout()
find<SessionRootPanel>(ui).doLayout()
}
private inline fun <reified T> find(root: java.awt.Container): T {
@@ -2,7 +2,7 @@ package ai.kilocode.client.session
import ai.kilocode.client.session.model.SessionModel
import ai.kilocode.client.session.model.SessionState
import ai.kilocode.client.session.ui.SessionPanel
import ai.kilocode.client.session.ui.SessionMessageListPanel
import ai.kilocode.client.session.views.TextView
import ai.kilocode.rpc.dto.MessageDto
import ai.kilocode.rpc.dto.MessageTimeDto
@@ -14,7 +14,7 @@ import com.intellij.testFramework.fixtures.BasePlatformTestCase
/**
* Integration test: mutate [SessionModel] directly on the EDT and verify
* that [SessionPanel] reflects the changes without any end-to-end RPC flow.
* that [SessionMessageListPanel] reflects the changes without any end-to-end RPC flow.
*
* This tests the full model → event → view update pipeline in isolation.
*/
@@ -23,13 +23,13 @@ class SessionUiUpdateTest : BasePlatformTestCase() {
private lateinit var model: SessionModel
private lateinit var parent: Disposable
private lateinit var panel: SessionPanel
private lateinit var panel: SessionMessageListPanel
override fun setUp() {
super.setUp()
parent = Disposer.newDisposable("test")
model = SessionModel()
panel = SessionPanel(model, parent)
panel = SessionMessageListPanel(model, parent)
}
override fun tearDown() {
@@ -4,6 +4,7 @@ import ai.kilocode.client.session.model.Tool
import ai.kilocode.client.session.model.ToolExecState
import ai.kilocode.client.session.model.SessionModelEvent
import ai.kilocode.client.session.model.SessionState
import ai.kilocode.client.session.update.SessionController
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.DiffFileDto
import ai.kilocode.rpc.dto.SessionStatusDto
@@ -4,7 +4,7 @@ import ai.kilocode.client.app.KiloAppService
import ai.kilocode.client.app.KiloSessionService
import ai.kilocode.client.app.KiloWorkspaceService
import ai.kilocode.client.app.Workspace
import ai.kilocode.client.session.SessionController
import ai.kilocode.client.session.update.SessionController
import ai.kilocode.client.session.model.Question
import ai.kilocode.client.session.model.QuestionItem
import ai.kilocode.client.session.model.QuestionOption
@@ -11,24 +11,24 @@ import com.intellij.openapi.util.Disposer
import com.intellij.testFramework.fixtures.BasePlatformTestCase
/**
* Tests for [SessionPanel] structural and index integrity.
* Tests for [SessionMessageListPanel] structural and index integrity.
*
* Uses [BasePlatformTestCase] for a real IntelliJ Application; layout
* is not measured (no screen), but the structural / index state is fully
* testable.
*/
@Suppress("UnstableApiUsage")
class SessionPanelTest : BasePlatformTestCase() {
class SessionMessageListPanelTest : BasePlatformTestCase() {
private lateinit var model: SessionModel
private lateinit var parent: Disposable
private lateinit var panel: SessionPanel
private lateinit var panel: SessionMessageListPanel
override fun setUp() {
super.setUp()
parent = Disposer.newDisposable("test")
model = SessionModel()
panel = SessionPanel(model, parent)
panel = SessionMessageListPanel(model, parent)
}
override fun tearDown() {
@@ -0,0 +1,70 @@
package ai.kilocode.client.session.ui
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import java.awt.Dimension
import java.awt.Rectangle
import javax.swing.JLayeredPane
import javax.swing.JPanel
@Suppress("UnstableApiUsage")
class SessionRootPanelTest : BasePlatformTestCase() {
fun `test root owns content and overlay layers`() {
val root = SessionRootPanel()
assertEquals(2, root.componentCount)
assertSame(root.content, root.components.first { it === root.content })
assertSame(root.overlay, root.components.first { it === root.overlay })
assertEquals(JLayeredPane.DEFAULT_LAYER, root.getLayer(root.content))
assertEquals(JLayeredPane.PALETTE_LAYER, root.getLayer(root.overlay))
}
fun `test root layout fills immediate children`() {
val root = SessionRootPanel().apply {
setSize(320, 180)
}
root.doLayout()
assertEquals(Rectangle(0, 0, 320, 180), root.content.bounds)
assertEquals(Rectangle(0, 0, 320, 180), root.overlay.bounds)
}
fun `test root preferred size is max of immediate children`() {
val root = SessionRootPanel().apply {
content.preferredSize = Dimension(300, 120)
overlay.preferredSize = Dimension(180, 220)
}
assertEquals(Dimension(300, 220), root.preferredSize)
}
fun `test addOverlay applies callback bounds and delegates child layout`() {
val root = SessionRootPanel().apply {
setSize(400, 260)
}
val child = Probe()
root.addOverlay(child) { _, item ->
Rectangle(12, 34, item.preferredSize.width, item.preferredSize.height)
}
root.doLayout()
assertEquals(Rectangle(12, 34, 80, 24), child.bounds)
assertTrue(child.laid)
}
private class Probe : JPanel() {
var laid = false
init {
preferredSize = Dimension(80, 24)
}
override fun doLayout() {
laid = true
super.doLayout()
}
}
}