mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 11:05:31 +08:00
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:
+1
-1
@@ -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)
|
||||
|
||||
+22
-47
@@ -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()
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
-282
@@ -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
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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() {
|
||||
+76
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
-11
@@ -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
-1
@@ -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
|
||||
+2
-2
@@ -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
-1
@@ -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
-1
@@ -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
|
||||
|
||||
+14
-2
@@ -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
@@ -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
|
||||
|
||||
+1
-2
@@ -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() {
|
||||
|
||||
|
||||
+8
-9
@@ -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 {
|
||||
|
||||
+4
-4
@@ -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() {
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+4
-4
@@ -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() {
|
||||
+70
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user