From bb5ddee22127c53654c74b71058eed16cd00c36b Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 24 Apr 2026 14:13:05 -0400 Subject: [PATCH] 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. --- .../kilocode/client/app/KiloSessionService.kt | 2 +- .../ai/kilocode/client/session/SessionUi.kt | 69 ++--- .../client/session/model/SessionModel.kt | 2 +- .../client/session/ui/ConnectionPanel.kt | 6 +- .../client/session/ui/MessageListUi.kt | 282 ------------------ .../client/session/ui/PermissionPanel.kt | 2 +- .../client/session/ui/ProgressPanel.kt | 2 +- .../client/session/ui/QuestionPanel.kt | 2 +- ...ionPanel.kt => SessionMessageListPanel.kt} | 2 +- .../client/session/ui/SessionRootPanel.kt | 76 +++++ .../session/{ => update}/SessionController.kt | 30 +- .../{ => update}/SessionControllerEvent.kt | 2 +- .../{ => update}/SessionQueueCondenser.kt | 4 +- .../{ => update}/SessionUpdateQueue.kt | 2 +- .../client/session/ListenerLifecycleTest.kt | 2 +- .../session/SessionControllerTestBase.kt | 16 +- .../session/SessionQueueCondenserTest.kt | 1 + .../client/session/SessionRecoveryTest.kt | 3 +- .../client/session/SessionUiLayoutTest.kt | 17 +- .../client/session/SessionUiUpdateTest.kt | 8 +- .../client/session/SessionUpdateQueueTest.kt | 1 + .../client/session/ui/QuestionPanelTest.kt | 2 +- ...Test.kt => SessionMessageListPanelTest.kt} | 8 +- .../client/session/ui/SessionRootPanelTest.kt | 70 +++++ 24 files changed, 235 insertions(+), 376 deletions(-) delete mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListUi.kt rename packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/{SessionPanel.kt => SessionMessageListPanel.kt} (99%) create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionRootPanel.kt rename packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/{ => update}/SessionController.kt (97%) rename packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/{ => update}/SessionControllerEvent.kt (96%) rename packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/{ => update}/SessionQueueCondenser.kt (96%) rename packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/{ => update}/SessionUpdateQueue.kt (99%) rename packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/{SessionPanelTest.kt => SessionMessageListPanelTest.kt} (96%) create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionRootPanelTest.kt 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 063922fcdb..5905fd0fa5 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 @@ -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) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index ae48935245..81fafa5a64 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -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() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt index 4fec5b07bb..0c93461c5e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt index eeba3d4783..5fd14f8f8a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListUi.kt deleted file mode 100644 index b7e9813570..0000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListUi.kt +++ /dev/null @@ -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() - 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() - private val labels = LinkedHashMap() - - 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}" - } -} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/PermissionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/PermissionPanel.kt index 0c105e5da2..6431996350 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/PermissionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/PermissionPanel.kt @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt index 75619241f7..3dea6a3bea 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt @@ -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( diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/QuestionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/QuestionPanel.kt index fcd83a223c..709f5b0844 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/QuestionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/QuestionPanel.kt @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt similarity index 99% rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionPanel.kt rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt index 7a44c4531e..4708c5a4d4 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt @@ -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() { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionRootPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionRootPanel.kt new file mode 100644 index 0000000000..0c13740eb1 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionRootPanel.kt @@ -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 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) + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt similarity index 97% rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionController.kt rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt index bdc6303385..edacb19247 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt @@ -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() 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 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionControllerEvent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionControllerEvent.kt similarity index 96% rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionControllerEvent.kt rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionControllerEvent.kt index 6026aed5e2..b872969180 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionControllerEvent.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionControllerEvent.kt @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionQueueCondenser.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionQueueCondenser.kt similarity index 96% rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionQueueCondenser.kt rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionQueueCondenser.kt index cb6b85d766..dcb06f4e6c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionQueueCondenser.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionQueueCondenser.kt @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUpdateQueue.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionUpdateQueue.kt similarity index 99% rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUpdateQueue.kt rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionUpdateQueue.kt index 2c02e7b334..5881e1bff0 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUpdateQueue.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionUpdateQueue.kt @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ListenerLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ListenerLifecycleTest.kt index a9a7de5c9d..f45af5a2eb 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ListenerLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ListenerLifecycleTest.kt @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt index 8e79cec7dc..83688adb05 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionQueueCondenserTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionQueueCondenserTest.kt index 0e5cf03e17..f114099717 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionQueueCondenserTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionQueueCondenserTest.kt @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionRecoveryTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionRecoveryTest.kt index 87d68d4ff3..05af93de74 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionRecoveryTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionRecoveryTest.kt @@ -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() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt index 73a2b15b1f..7522c71aa9 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt @@ -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(ui) + val root = find(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(ui).doLayout() + find(ui).doLayout() } private inline fun find(root: java.awt.Container): T { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiUpdateTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiUpdateTest.kt index 343271cdf7..c5044e1b34 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiUpdateTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiUpdateTest.kt @@ -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() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUpdateQueueTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUpdateQueueTest.kt index 04dc2ad4f3..ed084a1584 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUpdateQueueTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUpdateQueueTest.kt @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/QuestionPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/QuestionPanelTest.kt index 9f16b99529..769b5dbbe0 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/QuestionPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/QuestionPanelTest.kt @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt similarity index 96% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionPanelTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt index a0df7614d7..c2f2a68858 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt @@ -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() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionRootPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionRootPanelTest.kt new file mode 100644 index 0000000000..7d66c8604f --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionRootPanelTest.kt @@ -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() + } + } +}