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() + } + } +}