fix(jetbrains): preserve collapsed subagent views

This commit is contained in:
kirillk
2026-07-03 21:48:54 -04:00
parent ef76d67f8b
commit 166fe23ca4
12 changed files with 771 additions and 7 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Show subagent tool activity inline in JetBrains session transcripts.
@@ -894,7 +894,7 @@ class SessionController(
val job = cs.launch {
try {
sessions.events(child, directory).collect { event ->
if (!isChildPermissionEvent(event, child)) return@collect
if (!isChildEvent(event, child)) return@collect
LOG.debug { "${ChatLogSummary.sid(sid ?: "pending")} kind=child-event child=$child ${ChatLogSummary.eventBody(event)}" }
updates.enqueue(event)
}
@@ -914,6 +914,7 @@ class SessionController(
assertEdt()
if (!childIds.add(child)) return
subscribeChild(child)
cs.launch { seedChild(child) }
cs.launch { recoverChildPermissions(child) }
}
@@ -948,6 +949,27 @@ class SessionController(
}
}
private suspend fun seedChild(child: String) {
try {
val items = sessions.messages(child, directory)
runEdt {
if (disposed) return@runEdt
updateModel {
for (msg in items) {
if (msg.info.role != "assistant") continue
for (part in msg.parts) {
if (part.type == "tool") model.upsertChildTool(child, part, replace = false)
}
}
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
LOG.warn("${ChatLogSummary.sid(sid ?: "pending")} kind=child-history child=$child dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e)
}
}
/** Rehydrate pending permissions/questions and current session status after history load. */
private suspend fun recoverPending(id: String) {
try {
@@ -1026,6 +1048,10 @@ class SessionController(
}
is ChatEventDto.PartUpdated -> {
if (childIds.contains(event.sessionID)) {
if (event.part.type == "tool") model.upsertChildTool(event.sessionID, event.part)
return
}
partType = event.part.type
tool = event.part.tool
val key = PartKey(event.part.messageID, event.part.id)
@@ -1051,6 +1077,10 @@ class SessionController(
}
is ChatEventDto.PartRemoved -> {
if (childIds.contains(event.sessionID)) {
model.removeChildTool(event.sessionID, event.partID)
return
}
snapshots.remove(PartKey(event.messageID, event.partID))
model.removeContent(event.messageID, event.partID)
}
@@ -1918,8 +1948,10 @@ private fun childID(part: PartDto): String? {
return part.metadata["sessionId"]
}
/** Returns true when [event] is a permission event for [child] (used by child subscriptions). */
private fun isChildPermissionEvent(event: ChatEventDto, child: String): Boolean = when (event) {
/** Returns true when [event] should be routed from a child subscription. */
private fun isChildEvent(event: ChatEventDto, child: String): Boolean = when (event) {
is ChatEventDto.PartUpdated -> event.sessionID == child
is ChatEventDto.PartRemoved -> event.sessionID == child
is ChatEventDto.PermissionAsked -> event.sessionID == child
is ChatEventDto.PermissionReplied -> event.sessionID == child
else -> false
@@ -82,6 +82,8 @@ class Tool(id: String, val name: String, var kind: ToolKind) : Content(id) {
var title: String? = null
var input: Map<String, String> = emptyMap()
var metadata: Map<String, String> = emptyMap()
var childSessionId: String? = null
var childTools: List<Tool> = emptyList()
var output: String? = null
var error: String? = null
var time: PartTimeDto? = null
@@ -47,6 +47,8 @@ class SessionModel {
private val entries = LinkedHashMap<String, Message>()
private val turnEntries = LinkedHashMap<String, Turn>()
private val hiddenText = mutableSetOf<Pair<String, String>>()
private val childRefs = HashMap<String, ChildRef>()
private val childTools = HashMap<String, LinkedHashMap<String, Tool>>()
var app: KiloAppStateDto = KiloAppStateDto(KiloAppStatusDto.DISCONNECTED)
var version: String? = null
@@ -145,7 +147,8 @@ class SessionModel {
@RequiresEdt
fun removeMessage(id: String) {
if (entries.remove(id) == null) return
val msg = entries.remove(id) ?: return
for (part in msg.parts.values) untrackChild(part)
hiddenText.removeAll { it.first == id }
fire(SessionModelEvent.MessageRemoved(id))
regroup()
@@ -156,7 +159,8 @@ class SessionModel {
fun removeContent(messageId: String, contentId: String) {
hiddenText.remove(messageId to contentId)
val msg = entries[messageId] ?: return
if (msg.parts.remove(contentId) == null) return
val old = msg.parts.remove(contentId) ?: return
untrackChild(old)
fire(SessionModelEvent.ContentRemoved(messageId, contentId))
updateHeader()
}
@@ -186,10 +190,38 @@ class SessionModel {
}
val content = fromDto(dto)
msg.parts[dto.id] = content
trackChild(messageId, content)
fire(SessionModelEvent.ContentAdded(messageId, content))
updateHeader()
}
@RequiresEdt
fun upsertChildTool(child: String, dto: PartDto, replace: Boolean = true) {
if (dto.type != "tool") return
val ref = childRefs[child] ?: return
val msg = entries[ref.messageId] ?: return
val parent = msg.parts[ref.partId] as? Tool ?: return
val tool = fromDto(dto) as? Tool ?: return
val tools = childTools.getOrPut(child) { LinkedHashMap() }
if (!replace && tools.containsKey(dto.id)) return
tools[dto.id] = tool
parent.childTools = tools.values.toList()
fire(SessionModelEvent.ContentUpdated(ref.messageId, parent))
updateHeader()
}
@RequiresEdt
fun removeChildTool(child: String, partId: String) {
val ref = childRefs[child] ?: return
val tools = childTools[child] ?: return
if (tools.remove(partId) == null) return
val msg = entries[ref.messageId] ?: return
val parent = msg.parts[ref.partId] as? Tool ?: return
parent.childTools = tools.values.toList()
fire(SessionModelEvent.ContentUpdated(ref.messageId, parent))
updateHeader()
}
@RequiresEdt
fun appendDelta(messageId: String, contentId: String, delta: String) {
val msg = entries[messageId] ?: return
@@ -257,6 +289,8 @@ class SessionModel {
@RequiresEdt
fun loadHistory(history: List<MessageWithPartsDto>) {
entries.clear()
childRefs.clear()
childTools.clear()
hiddenText.clear()
session = null
state = SessionState.Idle
@@ -274,6 +308,7 @@ class SessionModel {
if (empty(part)) continue
val content = fromDto(part, part.text)
item.parts[content.id] = content
trackChild(msg.info.id, content)
}
entries[msg.info.id] = item
}
@@ -286,6 +321,8 @@ class SessionModel {
fun clear() {
entries.clear()
turnEntries.clear()
childRefs.clear()
childTools.clear()
hiddenText.clear()
session = null
state = SessionState.Idle
@@ -410,17 +447,24 @@ class SessionModel {
existing.source = dto.source
}
is Tool -> {
val old = existing.childSessionId
existing.kind = toolKind(dto.tool)
existing.state = parseToolState(dto.state)
existing.callId = dto.callID
existing.title = dto.title
existing.input = dto.input
existing.metadata = dto.metadata
existing.childSessionId = childID(existing)
if (old != null && old != existing.childSessionId) {
childRefs.remove(old)
childTools.remove(old)
}
existing.output = dto.output
existing.error = dto.error
existing.time = dto.time
existing.todos = dto.todos
existing.todoView = dto.todoView
trackChild(messageId, existing)
}
is Compaction -> return
is StepFinish -> {
@@ -461,6 +505,7 @@ class SessionModel {
title = dto.title
input = dto.input
metadata = dto.metadata
childSessionId = childID(this)
output = dto.output
error = dto.error
time = dto.time
@@ -481,6 +526,20 @@ class SessionModel {
for (l in listeners) l.onEvent(event)
}
private fun trackChild(messageId: String, content: Content) {
val tool = content as? Tool ?: return
val child = tool.childSessionId ?: return
childRefs[child] = ChildRef(messageId, tool.id)
tool.childTools = childTools[child]?.values?.toList() ?: emptyList()
}
private fun untrackChild(content: Content) {
val tool = content as? Tool ?: return
val child = tool.childSessionId ?: return
childRefs.remove(child)
childTools.remove(child)
}
private fun updateHeader() {
val next = buildHeader()
if (next == header) return
@@ -599,6 +658,13 @@ private fun parseToolState(raw: String?): ToolExecState = when (raw) {
else -> ToolExecState.PENDING
}
private data class ChildRef(val messageId: String, val partId: String)
private fun childID(tool: Tool): String? {
if (tool.name != "task") return null
return tool.metadata["sessionId"]
}
data class AgentItem(
val name: String,
val display: String,
@@ -165,6 +165,7 @@ object SessionUiStyle {
/** Tool session-view preview limits and state colors. */
object Tool {
const val BODY_LINES = 15
const val TASK_LINES = 10
const val PREVIEW_LIMIT = 20_000
fun pending(): Color = UiStyle.Colors.weak()
@@ -8,6 +8,7 @@ import ai.kilocode.client.session.views.tool.GlobToolView
import ai.kilocode.client.session.views.tool.ReadToolView
import ai.kilocode.client.session.views.tool.SearchToolView
import ai.kilocode.client.session.views.tool.ShellToolView
import ai.kilocode.client.session.views.tool.TaskToolView
import ai.kilocode.client.session.views.tool.ToolView
import ai.kilocode.client.session.ui.selection.SessionSelection
import ai.kilocode.client.session.model.Compaction
@@ -53,6 +54,7 @@ object ViewFactory {
GlobToolView.canRender(content) -> GlobToolView(content, selection = selection, repo = repo)
SearchToolView.canRender(content) -> SearchToolView(content, selection = selection, repo = repo)
ReadToolView.canRender(content) -> ReadToolView(content, openFile, selection = selection)
TaskToolView.canRender(content) -> TaskToolView(content, selection = selection)
else -> ToolView(content, selection = selection)
}
is Compaction -> CompactionView(content)
@@ -98,6 +100,8 @@ object ViewFactory {
if (view !is SearchToolView && SearchToolView.canRender(content)) return true
if (view is ReadToolView) return !ReadToolView.canRender(content) || QuestionResultView.canRender(content)
if (view is ToolView && ReadToolView.canRender(content)) return true
if (view is TaskToolView) return !TaskToolView.canRender(content) || QuestionResultView.canRender(content)
if (view !is TaskToolView && TaskToolView.canRender(content)) return true
if (view is ToolView) return QuestionResultView.canRender(content)
return false
}
@@ -0,0 +1,359 @@
package ai.kilocode.client.session.views.tool
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.model.Content
import ai.kilocode.client.session.model.Tool
import ai.kilocode.client.session.model.ToolExecState
import ai.kilocode.client.session.ui.selection.SessionSelection
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.views.base.SecondarySessionPartView
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.layout.Stack
import ai.kilocode.client.ui.layout.StackAxis
import com.intellij.openapi.actionSystem.DataSink
import com.intellij.openapi.actionSystem.UiDataProvider
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBScrollPane
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.util.ui.JBDimension
import com.intellij.util.ui.JBUI
import java.awt.BorderLayout
import java.awt.Dimension
import java.awt.Point
import java.awt.Rectangle
import javax.swing.JPanel
import javax.swing.ScrollPaneConstants
import javax.swing.Scrollable
import javax.swing.SwingUtilities
import kotlin.math.abs
class TaskToolView(
tool: Tool,
private val selection: SessionSelection? = null,
private val parts: ToolParts = toolParts(tool),
) : SecondarySessionPartView(parts.header, { TaskBody(parts.glyph).scroll }), UiDataProvider {
override val contentId: String = tool.id
private var item = tool
private var style = SessionEditorStyle.current()
private val rows = LinkedHashMap<String, Row>()
private var following = false
private var collapsed = false
init {
bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot)
applyStyle(style)
sync()
if (item.childTools.isNotEmpty()) expand()
}
override fun uiDataSnapshot(sink: DataSink) {
selection?.provideCopy(sink) { copyText() }
}
@RequiresEdt
override fun update(content: Content) {
if (content !is Tool) return
val fresh = item.childTools.isEmpty() && content.childTools.isNotEmpty()
item = content
val follow = tailVisible()
var changed = sync()
changed = syncRows() || changed
if (content.childTools.isNotEmpty() && !collapsed) changed = expand() || changed
followTail(follow || fresh)
if (changed) refresh()
}
@RequiresEdt
override fun expand(): Boolean {
collapsed = false
val changed = super.expand()
syncRows()
return changed
}
@RequiresEdt
override fun collapse(): Boolean {
if (item.childTools.isNotEmpty() && isExpanded()) collapsed = true
return super.collapse()
}
@RequiresEdt
fun labelText(): String = listOf(parts.title.text, subtitleText(parts), parts.state.text)
.filter { it.isNotBlank() }
.joinToString(" ")
@RequiresEdt
fun rowCount(): Int = rows.size
@RequiresEdt
fun rowLabels(): List<String> = rows.values.map { row -> row.text() }
@RequiresEdt
fun bodyCreated(): Boolean = hasBody()
@RequiresEdt
fun bodyVisible(): Boolean = isExpanded()
@RequiresEdt
fun controlCount(): Int = if (arrow.isVisible) 1 else 0
@RequiresEdt
internal fun bodyMaxRows() = SessionUiStyle.View.Tool.TASK_LINES
@RequiresEdt
internal fun bodyScrollValue() = taskBodyOrNull()?.verticalScrollBar?.value ?: 0
@RequiresEdt
internal fun bodyScrollBottom() = taskBodyOrNull()?.let(::bottom) ?: 0
@RequiresEdt
internal fun setBodyScrollValue(value: Int) {
taskBodyOrNull()?.verticalScrollBar?.value = value
}
@RequiresEdt
internal fun horizontalPolicy() = taskBodyOrNull()?.horizontalScrollBarPolicy ?: ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
@RequiresEdt
internal fun verticalPolicy() = taskBodyOrNull()?.verticalScrollBarPolicy ?: ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER
@RequiresEdt
internal fun bodyInsets() = taskBody().panel.border.getBorderInsets(taskBody().panel)
@RequiresEdt
internal fun rowTitleColor(id: String) = rows[id]?.title?.foreground
@RequiresEdt
override fun applyStyle(style: SessionEditorStyle) {
this.style = style
var changed = false
changed = setFont(parts.title, style.boldEditorFont) || changed
changed = setFont(parts.sub, style.smallEditorFont) || changed
changed = setFont(parts.state, style.smallEditorFont) || changed
for (row in rows.values) changed = row.applyStyle(style) || changed
if (changed) refresh()
}
@RequiresEdt
override fun getPreferredSize(): Dimension {
val size = super.getPreferredSize()
if (!bodyVisible()) return size
val height = row.preferredSize.height + bodyMaxHeight()
return Dimension(size.width, minOf(size.height, height))
}
private fun sync(): Boolean {
var changed = false
changed = syncExpandable(item.childTools.isNotEmpty()) || changed
changed = setVisible(parts.state, item.childTools.isEmpty()) || changed
changed = setIcon(parts.glyph, icon(item)) || changed
changed = setForeground(parts.glyph, color(item)) || changed
changed = setText(parts.title, agentTitle(item)) || changed
changed = setText(parts.sub, summary(item)) || changed
changed = setForeground(parts.title, titleColor(item)) || changed
changed = setText(parts.state, stateText(item)) || changed
changed = setForeground(parts.state, color(item)) || changed
return changed
}
private fun syncRows(): Boolean {
if (!hasBody()) return false
val body = taskBody()
var changed = false
val ids = item.childTools.map { tool -> tool.id }.toSet()
val stale = rows.keys.filter { id -> id !in ids }
for (id in stale) {
val row = rows.remove(id) ?: continue
body.rows.remove(row.panel)
changed = true
}
for (tool in item.childTools) {
val row = rows[tool.id]
if (row == null) {
val next = Row(tool).also { it.applyStyle(style) }
rows[tool.id] = next
body.rows.next(next.panel)
changed = true
continue
}
changed = row.update(tool) || changed
}
if (changed) {
body.rows.revalidate()
body.rows.repaint()
}
return changed
}
private fun taskBody() = bodyComponent() as TaskBodyScroll
private fun taskBodyOrNull() = if (hasBody()) bodyComponent() as? TaskBodyScroll else null
private fun bodyMaxHeight(): Int {
val body = taskBodyOrNull() ?: return 0
val height = rows.values.firstOrNull()?.panel?.getFontMetrics(style.smallEditorFont)?.height
?: body.rows.getFontMetrics(style.smallEditorFont).height
return height * bodyMaxRows() + JBUI.scale(SessionUiStyle.View.Layout.BODY_EXTRA_HEIGHT)
}
@RequiresEdt
private fun tailVisible(): Boolean {
if (!bodyVisible()) return false
val scroll = taskBodyOrNull() ?: return false
val bar = scroll.verticalScrollBar
val bottom = bar.maximum - bar.visibleAmount
return bottom > 0 && abs(bar.value - bottom) <= UiStyle.Gap.pad()
}
@RequiresEdt
private fun followTail(follow: Boolean) {
if (!follow || !bodyVisible() || following) return
val scroll = taskBodyOrNull() ?: return
following = true
SwingUtilities.invokeLater { followPass(scroll, 4) }
}
@RequiresEdt
private fun followPass(scroll: JBScrollPane, passes: Int) {
if (!bodyVisible()) {
following = false
return
}
val view = scroll.viewport.view
view?.setSize(scroll.viewport.extentSize.width.coerceAtLeast(1), view.preferredSize.height)
view?.doLayout()
scroll.viewport.doLayout()
scroll.doLayout()
scroll.viewport.viewPosition = Point(0, bottom(scroll))
scroll.verticalScrollBar.value = bottom(scroll)
if (passes <= 0 || scroll.verticalScrollBar.value == bottom(scroll)) {
following = false
return
}
SwingUtilities.invokeLater { followPass(scroll, passes - 1) }
}
private fun bottom(scroll: JBScrollPane): Int {
val view = scroll.viewport.view ?: return 0
return maxOf(0, view.height - scroll.viewport.extentSize.height)
}
private fun copyText(): String = buildString {
append(agentTitle(item))
val desc = item.input["description"].orEmpty()
if (desc.isNotBlank()) append(" - ").append(desc)
for (tool in item.childTools) {
append('\n')
append(title(tool))
val sub = subtitle(tool)
if (sub.isNotBlank()) append(' ').append(sub)
}
}
private class Row(tool: Tool) {
private var item = tool
val icon = JBLabel()
val title = JBLabel()
val sub = JBLabel().apply { foreground = UiStyle.Colors.weak() }
val panel = JPanel(BorderLayout(UiStyle.Gap.md(), 0)).apply {
isOpaque = false
add(icon, BorderLayout.WEST)
add(JPanel(BorderLayout(UiStyle.Gap.sm(), 0)).apply {
isOpaque = false
add(title, BorderLayout.WEST)
add(sub, BorderLayout.CENTER)
}, BorderLayout.CENTER)
}
@RequiresEdt
fun update(tool: Tool): Boolean {
item = tool
var changed = false
changed = setIcon(icon, icon(tool)) || changed
changed = setForeground(icon, color(tool)) || changed
changed = setText(title, title(tool)) || changed
changed = setForeground(title, rowTitleColor(tool)) || changed
changed = setText(sub, subtitle(tool)) || changed
return changed
}
@RequiresEdt
fun applyStyle(style: SessionEditorStyle): Boolean {
var changed = false
changed = setFont(title, style.boldEditorFont) || changed
changed = setFont(sub, style.smallEditorFont) || changed
return update(item) || changed
}
@RequiresEdt
fun text(): String = listOf(title.text, sub.text).filter { it.isNotBlank() }.joinToString(" ")
}
override fun dumpLabel() = "TaskToolView#$contentId(${labelText()})"
companion object {
fun canRender(content: Tool): Boolean = content.name == "task"
}
}
private class TaskBody(glyph: JBLabel) {
val rows = TaskRows()
val panel = JPanel(BorderLayout()).apply {
isOpaque = true
background = SessionUiStyle.View.Surface.bgColor()
border = JBUI.Borders.empty(
UiStyle.Gap.sm(),
glyph.preferredSize.width + JBUI.scale(SessionUiStyle.View.Layout.GAP) + UiStyle.Gap.md(),
UiStyle.Gap.sm(),
UiStyle.Gap.md(),
)
add(rows, BorderLayout.CENTER)
}
val scroll = TaskBodyScroll(this)
}
private class TaskBodyScroll(val body: TaskBody) : JBScrollPane(body.panel) {
val rows: Stack get() = body.rows
val panel: JPanel get() = body.panel
init {
border = JBUI.Borders.empty()
isOpaque = true
background = SessionUiStyle.View.Surface.bgColor()
viewport.background = SessionUiStyle.View.Surface.bgColor()
horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED
}
}
private class TaskRows : Stack(StackAxis.VERTICAL, UiStyle.Gap.sm()), Scrollable {
override fun getScrollableTracksViewportWidth() = true
override fun getScrollableTracksViewportHeight() = false
override fun getPreferredScrollableViewportSize(): Dimension = preferredSize
override fun getScrollableUnitIncrement(
visibleRect: Rectangle,
orientation: Int,
direction: Int,
) = JBUI.scale(SessionUiStyle.SessionLayout.SCROLL_INCREMENT)
override fun getScrollableBlockIncrement(
visibleRect: Rectangle,
orientation: Int,
direction: Int,
) = visibleRect.height
override fun getMaximumSize() = JBDimension(Int.MAX_VALUE, super.getMaximumSize().height)
}
private fun rowTitleColor(tool: Tool) = if (tool.state == ToolExecState.ERROR) {
UiStyle.Colors.errorLabelForeground()
} else {
UiStyle.Colors.weak()
}
private fun agentTitle(tool: Tool): String {
val type = tool.input["subagent_type"]?.takeIf { it.isNotBlank() } ?: tool.name
return KiloBundle.message("session.part.tool.agent", type.replaceFirstChar { it.titlecase() })
}
private fun summary(tool: Tool): String {
val desc = tool.input["description"].orEmpty()
val count = tool.childTools.size
if (count <= 0) return desc
if (desc.isBlank()) return "($count)"
return "$desc ($count)"
}
@@ -93,6 +93,7 @@ session.part.reasoning=Reasoning
session.part.compaction=context compacted
session.part.tool.copy=Copy
session.part.tool.error=Error
session.part.tool.agent={0} Agent
session.part.tool.pending=Pending
session.part.tool.read=Read
session.part.tool.glob=Glob
@@ -1,12 +1,17 @@
package ai.kilocode.client.session.controller
import ai.kilocode.client.plugin.KiloPluginSettings
import ai.kilocode.client.session.SessionRef
import ai.kilocode.client.session.model.PermissionFileDiff
import ai.kilocode.client.session.model.PermissionMeta
import ai.kilocode.client.session.model.SessionState
import ai.kilocode.client.session.SessionRef
import ai.kilocode.client.session.model.Tool
import ai.kilocode.rpc.dto.AgentDto
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.ConfigDto
import ai.kilocode.rpc.dto.KiloAppStateDto
import ai.kilocode.rpc.dto.KiloAppStatusDto
import ai.kilocode.rpc.dto.MessageWithPartsDto
import ai.kilocode.rpc.dto.ModelDto
import ai.kilocode.rpc.dto.PartDto
import ai.kilocode.rpc.dto.PartSourceDto
@@ -23,6 +28,7 @@ import ai.kilocode.rpc.dto.QuestionReplyDto
import ai.kilocode.rpc.dto.QuestionRequestDto
import ai.kilocode.rpc.dto.ToolRefDto
import java.util.concurrent.CopyOnWriteArrayList
import kotlinx.coroutines.CompletableDeferred
class PromptLifecycleTest : SessionControllerTestBase() {
@@ -626,6 +632,78 @@ class PromptLifecycleTest : SessionControllerTestBase() {
assertTrue("Root state must not be changed by child non-permission events", stateEvents.isEmpty())
}
fun `test child tool update is stored on parent task part`() {
val (m, _, modelEvents) = prompted()
emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")), flush = false)
emit(taskPart("ses_child"), flush = false)
emit(ChatEventDto.PartUpdated("ses_child", childTool("child_read", "read")))
val task = m.model.content("msg1", "part_task") as Tool
assertEquals("ses_child", task.childSessionId)
assertEquals(1, task.childTools.size)
assertEquals("read", task.childTools[0].name)
assertNull(m.model.content("child_msg", "child_read"))
assertTrue(modelEvents.any { it.toString() == "ContentUpdated msg1/part_task" })
}
fun `test child tool removed updates parent task part`() {
val (m, _, _) = prompted()
emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")), flush = false)
emit(taskPart("ses_child"), flush = false)
emit(ChatEventDto.PartUpdated("ses_child", childTool("child_read", "read")), flush = false)
emit(ChatEventDto.PartRemoved("ses_child", "child_msg", "child_read"))
val task = m.model.content("msg1", "part_task") as Tool
assertTrue(task.childTools.isEmpty())
}
fun `test history load backfills child tools`() {
appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, config = ConfigDto(model = "kilo/gpt-5"))
projectRpc.state.value = workspaceReady()
rpc.histories["ses_test"] = mutableListOf(
MessageWithPartsDto(
msg("msg1", "ses_test", "assistant"),
listOf(taskPart("ses_child").part),
),
)
rpc.histories["ses_child"] = mutableListOf(
MessageWithPartsDto(
msg("child_msg", "ses_child", "assistant"),
listOf(childTool("child_read", "read"), childTool("child_grep", "grep")),
),
)
val m = controller("ses_test")
flush()
val task = m.model.content("msg1", "part_task") as Tool
assertEquals(listOf("read", "grep"), task.childTools.map { it.name })
assertNull(m.model.content("child_msg", "child_read"))
}
fun `test stale child history does not overwrite live child tool update`() {
rpc.historyGate = CompletableDeferred()
rpc.histories["ses_child"] = mutableListOf(
MessageWithPartsDto(
msg("child_msg", "ses_child", "assistant"),
listOf(childTool("child_read", "grep")),
),
)
val (m, _, _) = prompted()
emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")), flush = false)
emit(taskPart("ses_child"), flush = false)
emit(ChatEventDto.PartUpdated("ses_child", childTool("child_read", "read")))
var task = m.model.content("msg1", "part_task") as Tool
assertEquals(listOf("read"), task.childTools.map { it.name })
rpc.historyGate!!.complete(Unit)
flush()
task = m.model.content("msg1", "part_task") as Tool
assertEquals(listOf("read"), task.childTools.map { it.name })
}
fun `test root permission event is not processed as child permission`() {
val (m, _, _) = prompted()
@@ -646,9 +724,20 @@ class PromptLifecycleTest : SessionControllerTestBase() {
type = "tool",
tool = "task",
metadata = mapOf("sessionId" to childSessionId),
input = mapOf("subagent_type" to "explore", "description" to "Find files"),
),
)
private fun childTool(id: String, name: String) = PartDto(
id = id,
sessionID = "ses_child",
messageID = "child_msg",
type = "tool",
tool = name,
state = "completed",
input = mapOf("filePath" to "src/Main.kt", "pattern" to "query"),
)
private fun childPermission(id: String) = PermissionRequestDto(
id = id,
sessionID = "ses_child",
@@ -20,6 +20,7 @@ import ai.kilocode.client.session.views.MessageToolbar
import ai.kilocode.client.session.views.MessageView
import ai.kilocode.client.session.views.TextView
import ai.kilocode.client.session.views.base.PartView
import ai.kilocode.client.session.views.tool.TaskToolView
import ai.kilocode.client.session.views.tool.ToolView
import ai.kilocode.client.session.views.todo.TodoWriteView
import ai.kilocode.rpc.dto.MessageDto
@@ -376,6 +377,33 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
assertTrue(mv.partIds().isEmpty())
}
fun `test child tool update refreshes collapsed task view without replacing it`() {
model.upsertMessage(msg("a1", "assistant"))
model.updateContent(
"a1",
toolPart(
"part_task",
"a1",
"task",
"call_task",
input = mapOf("subagent_type" to "explore", "description" to "Find files"),
metadata = mapOf("sessionId" to "ses_child"),
),
)
model.upsertChildTool("ses_child", childTool("child_read", "read"))
val view = panel.findMessage("a1")!!.part("part_task") as TaskToolView
assertTrue(view.bodyVisible())
view.collapse()
model.upsertChildTool("ses_child", childTool("child_read", "grep"))
val updated = panel.findMessage("a1")!!.part("part_task") as TaskToolView
assertSame(view, updated)
assertFalse(updated.bodyVisible())
assertTrue(updated.rowLabels().single().contains("Grep"))
assertTrue(updated.rowLabels().single().contains("pattern=query"))
}
// ------ HistoryLoaded ------
fun `test HistoryLoaded rebuilds panel from scratch`() {
@@ -804,6 +832,16 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
input = input, metadata = metadata, todos = todos,
)
private fun childTool(id: String, tool: String) = PartDto(
id = id,
sessionID = "ses_child",
messageID = "child_msg",
type = "tool",
tool = tool,
state = "completed",
input = mapOf("filePath" to "src/Main.kt", "pattern" to "query"),
)
private fun root(view: QuestionResultView) = view.components[0] as JPanel
private fun header(view: QuestionResultView) = root(view).components[0] as JPanel
@@ -0,0 +1,166 @@
package ai.kilocode.client.session.views
import ai.kilocode.client.session.model.Tool
import ai.kilocode.client.session.model.ToolExecState
import ai.kilocode.client.session.model.toolKind
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.views.base.SecondarySessionPartView
import ai.kilocode.client.session.views.tool.TaskToolView
import ai.kilocode.client.ui.UiStyle
import com.intellij.openapi.util.Disposer
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import java.awt.Color
import javax.swing.ScrollPaneConstants
@Suppress("UnstableApiUsage")
class TaskToolViewTest : BasePlatformTestCase() {
private val views = mutableListOf<TaskToolView>()
override fun tearDown() {
try {
views.forEach(Disposer::dispose)
views.clear()
} finally {
super.tearDown()
}
}
fun `test task tool uses secondary chrome`() {
val base: Any = view(task())
assertTrue(base is SecondarySessionPartView)
}
fun `test task header shows agent description and count`() {
val view = view(task(children = listOf(child("c1", "read"), child("c2", "grep"))))
assertTrue(view.labelText().contains("Explore Agent"))
assertTrue(view.labelText().contains("Find files (2)"))
assertEquals(2, view.rowCount())
assertTrue(view.bodyVisible())
}
fun `test update adds child row without replacing existing rows`() {
val view = view(task(children = listOf(child("c1", "read"))))
val before = view.rowLabels().first()
view.update(task(children = listOf(child("c1", "read"), child("c2", "grep"))))
assertEquals(2, view.rowCount())
assertEquals(before, view.rowLabels().first())
assertTrue(view.rowLabels().any { it.contains("Grep") })
}
fun `test removing child rows collapses body`() {
val view = view(task(children = listOf(child("c1", "read"))))
view.update(task(children = emptyList()))
assertEquals(0, view.rowCount())
assertFalse(view.bodyVisible())
}
fun `test body is lazy until child tools arrive`() {
val view = view(task(children = emptyList()))
assertFalse(view.bodyCreated())
view.update(task(children = listOf(child("c1", "read"))))
assertTrue(view.bodyCreated())
assertTrue(view.bodyVisible())
}
fun `test collapsed task body stays collapsed on child update`() {
val view = view(task(children = listOf(child("c1", "read"))))
view.collapse()
view.update(task(children = listOf(child("c1", "grep"))))
assertFalse(view.bodyVisible())
assertTrue(view.rowLabels().single().contains("Grep"))
assertTrue(view.rowLabels().single().contains("pattern=query"))
}
fun `test expanded task body is capped to ten rows`() {
val view = view(task(children = children(20)))
val taller = view(task(children = children(80)))
assertEquals(10, view.bodyMaxRows())
assertTrue(view.preferredSize.height > 0)
assertEquals(view.preferredSize.height, taller.preferredSize.height)
}
fun `test task body uses nested vertical scroll`() {
val view = view(task(children = children(20)))
assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, view.horizontalPolicy())
assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, view.verticalPolicy())
}
fun `test child tool titles use target color`() {
val view = view(task(children = listOf(child("c1", "read"), child("c2", "grep", ToolExecState.ERROR))))
assertColor(UiStyle.Colors.weak(), view.rowTitleColor("c1"))
assertColor(UiStyle.Colors.errorLabelForeground(), view.rowTitleColor("c2"))
}
fun `test task body is indented beyond header padding`() {
val view = view(task(children = listOf(child("c1", "read"))))
assertTrue(view.bodyInsets().left > JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING))
assertEquals(UiStyle.Gap.sm(), view.bodyInsets().top)
assertEquals(UiStyle.Gap.sm(), view.bodyInsets().bottom)
}
fun `test appended child tools scroll nested body to bottom`() {
val view = view(task(children = children(40)))
view.setSize(300, view.preferredSize.height)
view.doLayout()
UIUtil.dispatchAllInvocationEvents()
view.setBodyScrollValue(view.bodyScrollBottom() - 1)
view.update(task(children = children(70)))
UIUtil.dispatchAllInvocationEvents()
UIUtil.dispatchAllInvocationEvents()
UIUtil.dispatchAllInvocationEvents()
assertEquals(view.bodyScrollBottom(), view.bodyScrollValue())
}
fun `test appended child tools do not yank nested body above tail`() {
val view = view(task(children = children(40)))
view.setSize(300, view.preferredSize.height)
view.doLayout()
UIUtil.dispatchAllInvocationEvents()
view.setBodyScrollValue(0)
view.update(task(children = children(70)))
UIUtil.dispatchAllInvocationEvents()
assertEquals(0, view.bodyScrollValue())
}
private fun view(tool: Tool): TaskToolView = TaskToolView(tool).also { views.add(it) }
private fun task(children: List<Tool> = emptyList()) = Tool("part_task", "task", toolKind("task")).also {
it.state = ToolExecState.COMPLETED
it.input = mapOf("subagent_type" to "explore", "description" to "Find files")
it.metadata = mapOf("sessionId" to "ses_child")
it.childSessionId = "ses_child"
it.childTools = children
}
private fun child(id: String, name: String, state: ToolExecState = ToolExecState.COMPLETED) = Tool(id, name, toolKind(name)).also {
it.state = state
it.input = mapOf("filePath" to "src/Main.kt", "pattern" to "query")
}
private fun children(count: Int) = (1..count).map { child("c$it", "read") }
private fun assertColor(expected: Color, actual: Color?) {
assertNotNull(actual)
assertEquals(expected.rgb, actual!!.rgb)
}
}
@@ -46,6 +46,7 @@ class FakeSessionRpcApi : KiloSessionRpcApi {
/** Message history returned by [messages]. */
val history = mutableListOf<MessageWithPartsDto>()
val histories = mutableMapOf<String, MutableList<MessageWithPartsDto>>()
var historyGate: CompletableDeferred<Unit>? = null
var historyCalls = 0
private set
@@ -218,7 +219,7 @@ class FakeSessionRpcApi : KiloSessionRpcApi {
assertNotEdt("messages")
historyCalls++
historyGate?.await()
return history.toList()
return histories[id]?.toList() ?: history.toList()
}
override suspend fun attachmentPart(id: String, directory: String, messageId: String, partId: String, attachmentKey: String?): PartDto? {