Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/jetbrains-swarm-markdown-messages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": minor
---

Render Kilo Swarm board messages as stacked Markdown with agent identities and full syntax highlighting.
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
package ai.kilocode.client.session.board

import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.ui.HoverArea
import ai.kilocode.client.ui.PlainLabel
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.layout.Stack
import ai.kilocode.client.ui.layout.StackAxis
import ai.kilocode.client.ui.md.MdViewFactory
import ai.kilocode.rpc.dto.BoardMessageDto
import com.intellij.openapi.Disposable
import com.intellij.openapi.util.Disposer
import com.intellij.ui.components.JBLabel
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.SwingTextTrimmer
import java.awt.BorderLayout
import java.awt.Dimension
import java.awt.Rectangle
import javax.swing.JComponent
import javax.swing.JPanel
import javax.swing.Scrollable
import javax.swing.SwingConstants

/** Width-tracking board body that retains one Markdown view per loaded message. */
internal class BoardMessagesView @RequiresEdt constructor(
private val avatars: BoardAvatars,
private val open: (String, String?) -> Unit,
) : JPanel(BorderLayout()), Scrollable, Disposable {
private val empty = JBLabel(KiloBundle.message("session.board.empty"), SwingConstants.CENTER)
private val stack = Stack.vertical(UiStyle.Gap.pad())
private var rows = linkedMapOf<String, BoardMessageView>()

init {
add(empty, BorderLayout.CENTER)
add(stack, BorderLayout.NORTH)
sync(emptyList())
}

@RequiresEdt
fun sync(items: List<BoardMessageDto>) {
val next = LinkedHashMap<String, BoardMessageView>()
val seen = HashSet<String>()
for (item in items) {
if (!seen.add(item.id)) continue
val row = rows.remove(item.id) ?: BoardMessageView(avatars, open)
Comment thread
kirillk marked this conversation as resolved.
Resolved
row.update(item)
row.separated = next.isNotEmpty()
next[item.id] = row
}
rows.values.forEach(Disposer::dispose)
rows = next

stack.removeAll()
rows.values.forEach { stack.add(it) }
empty.isVisible = rows.isEmpty()
stack.isVisible = rows.isNotEmpty()
revalidate()
repaint()
}

@RequiresEdt
override fun updateUI() {
super.updateUI()
border = JBUI.Borders.empty(0, UiStyle.Gap.PAD, UiStyle.Gap.PAD, UiStyle.Gap.PAD)
}

override fun getScrollableTracksViewportWidth() = true

override fun getScrollableTracksViewportHeight() = rows.isEmpty()

override fun getPreferredScrollableViewportSize(): Dimension = preferredSize

override fun getScrollableUnitIncrement(
visibleRect: Rectangle,
orientation: Int,
direction: Int,
) = UiStyle.Gap.pad()

override fun getScrollableBlockIncrement(
visibleRect: Rectangle,
orientation: Int,
direction: Int,
) = visibleRect.height

@RequiresEdt
override fun dispose() {
rows.values.forEach(Disposer::dispose)
rows.clear()
stack.removeAll()
}
}

/** One board message: a participant route followed by the original Markdown body. */
internal class BoardMessageView @RequiresEdt constructor(
avatars: BoardAvatars,
open: (String, String?) -> Unit,
) : Stack(StackAxis.VERTICAL, UiStyle.Gap.sm()), Disposable {
private val style = SessionEditorStyle.current()
private val sender = RouteMember(avatars, open)
private val recipient = RouteMember(avatars, open)
private val label = PlainLabel().apply {
font = style.smallFont
foreground = UiStyle.Colors.weak()
putClientProperty(SwingTextTrimmer.KEY, SwingTextTrimmer.ELLIPSIS_AT_RIGHT)
}
private val route = JPanel(BorderLayout(UiStyle.Gap.md(), 0)).apply {
isOpaque = false
add(sender.component, BorderLayout.WEST)
add(label, BorderLayout.CENTER)
add(recipient.component, BorderLayout.EAST)
}
private val md = MdViewFactory.create(style).apply {
opaque = false
font = style.transcriptFont
foreground = UiStyle.Colors.fg()
}

var separated = false
set(value) {
if (field == value) return
field = value
syncBorder()
}

init {
Disposer.register(this, md)
next(route)
next(md.component)
syncBorder()
}

@RequiresEdt
fun update(message: BoardMessageDto) {
sender.update(message.from, message.fromLabel)
recipient.update(message.to, message.toLabel)
val from = message.fromLabel ?: message.from
val to = message.toLabel ?: message.to
label.text = "$from \u2192 $to"
route.accessibleContext.accessibleName = label.text
if (md.markdown() != message.body) md.set(message.body)
}

@RequiresEdt
override fun updateUI() {
super.updateUI()
syncBorder()
}

@RequiresEdt
private fun syncBorder() {
border = if (separated) {
JBUI.Borders.compound(
JBUI.Borders.customLineTop(UiStyle.Colors.contentBorder()),
JBUI.Borders.emptyTop(UiStyle.Gap.PAD),
)
} else {
JBUI.Borders.empty()
}
}

override fun getMaximumSize() = Dimension(Int.MAX_VALUE, super.getMaximumSize().height)

override fun dispose() = Unit
}

private class RouteMember @RequiresEdt constructor(
private val avatars: BoardAvatars,
private val open: (String, String?) -> Unit,
) {
private val icon = JBLabel()
private val area = HoverArea(icon)
val component: JComponent get() = area

@RequiresEdt
fun update(id: String, label: String?) {
val title = label ?: id
icon.icon = avatars.icon(id)
area.action = id.takeIf(::isSubagent)?.let { target -> { open(target, label) } }
Comment thread
kirillk marked this conversation as resolved.
Resolved
val tip = if (title == id) title else "$title ($id)"
area.tooltip(tip, title)
}
}

private fun isSubagent(id: String): Boolean = id != "main" && id != "ALL"
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,6 @@ import ai.kilocode.client.app.KiloSessionService
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.layout.Stack
import ai.kilocode.client.ui.list.ActiveList
import ai.kilocode.client.ui.list.ActiveListBadge
import ai.kilocode.client.ui.list.ActiveListConfig
import ai.kilocode.client.ui.list.ActiveListIconAlignment
import ai.kilocode.client.ui.list.ActiveListItem
import ai.kilocode.client.ui.list.ActiveListRowHeight
import ai.kilocode.log.KiloLog
import ai.kilocode.rpc.dto.BoardMessageDto
import ai.kilocode.rpc.dto.SessionBoardDto
Expand All @@ -22,11 +16,13 @@ import com.intellij.openapi.ui.DialogWrapper
import com.intellij.openapi.ui.Messages
import com.intellij.openapi.ui.popup.Balloon
import com.intellij.openapi.ui.popup.JBPopupFactory
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.wm.WindowManager
import com.intellij.ui.EditorNotificationPanel
import com.intellij.ui.InlineBanner
import com.intellij.ui.awt.RelativePoint
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
Expand All @@ -41,6 +37,7 @@ import javax.swing.Action
import javax.swing.JButton
import javax.swing.JComponent
import javax.swing.JPanel
import javax.swing.ScrollPaneConstants
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
Expand Down Expand Up @@ -83,31 +80,15 @@ internal class SessionBoardDialog(
) == Messages.YES
}

private data class Row(
override val key: String,
override val title: String,
override val description: String?,
override val icon: javax.swing.Icon?,
override val badges: List<ActiveListBadge>,
val from: String,
val fromLabel: String?,
val to: String,
val toLabel: String?,
) : ActiveListItem

// internal (not private): lets tests simulate real clicks/selection on the live list.
internal val list = ActiveList(
emptyText = KiloBundle.message("session.board.empty"),
cfg = ActiveListConfig(
height = ActiveListRowHeight.PREFERRED,
tooltip = false,
iconAlignment = ActiveListIconAlignment.TOP,
wrapDescription = true,
),
showSearch = false,
onCell = { _, _ -> },
onClick = { item -> (item as? Row)?.let(::openParticipant) },
)
internal val messages = BoardMessagesView(avatars, onOpenAgent)
internal val scroll = JBScrollPane(messages).apply {
border = JBUI.Borders.empty()
viewportBorder = JBUI.Borders.empty()
horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED
isFocusable = true
accessibleContext.accessibleName = KiloBundle.message("session.board.title")
}

internal val loadMoreButton = button(KiloBundle.message("session.board.loadMore")) { load(before = board?.cursor) }
internal val resetButton = button(KiloBundle.message("session.board.reset.action")) { onReset() }
Expand Down Expand Up @@ -145,12 +126,13 @@ internal class SessionBoardDialog(
// which is the point of watching it. Callers must use show(); showAndGet() throws on a
// non-modal dialog.
isModal = false
Disposer.register(disposable, messages)
init()
load(before = null)
}

override fun createCenterPanel(): JComponent {
list.preferredSize = JBDimension(0, DIALOG_HEIGHT)
scroll.preferredSize = JBDimension(0, DIALOG_HEIGHT)
syncBanner()
val panel = object : JPanel(BorderLayout()) {
/**
Expand All @@ -169,7 +151,7 @@ internal class SessionBoardDialog(
},
BorderLayout.NORTH,
)
panel.add(list, BorderLayout.CENTER)
panel.add(scroll, BorderLayout.CENTER)
return panel
}

Expand Down Expand Up @@ -200,6 +182,8 @@ internal class SessionBoardDialog(

override fun createActions(): Array<Action> = emptyArray()

override fun getPreferredFocusedComponent(): JComponent = scroll

override fun createSouthPanel(): JComponent = JPanel(BorderLayout()).apply {
isOpaque = false
border = JBUI.Borders.empty(UiStyle.Gap.pad())
Expand Down Expand Up @@ -240,7 +224,7 @@ internal class SessionBoardDialog(
revision = page.revision,
) else page
hideError()
list.update(board?.messages.orEmpty().map(::row))
messages.sync(board?.messages.orEmpty())
}

private fun onReset() {
Expand All @@ -260,49 +244,14 @@ internal class SessionBoardDialog(
}
board = updated
hideError()
list.update(updated.messages.map(::row))
messages.sync(updated.messages)
}
result.onFailure { fail(KiloBundle.message("session.board.reset.failed"), it) }
syncButtons()
}
}
}

/**
* Opens whichever route endpoint is a subagent, regardless of message direction: both
* `main -> agent` and `agent -> main` open `agent`. A route between two subagents opens the
* sender, matching the row's own reading order. Never closes the board — it stays open behind
* the newly opened (or focused) editor tab.
*/
private fun openParticipant(row: Row) {
val (id, label) = openTarget(row) ?: return
onOpenAgent(id, label)
}

private fun openTarget(row: Row): Pair<String, String?>? {
if (isSubagent(row.from)) return row.from to row.fromLabel
if (isSubagent(row.to)) return row.to to row.toLabel
return null
}

private fun isSubagent(participant: String): Boolean = participant != "main" && participant != "ALL"

private fun row(message: BoardMessageDto): Row {
val from = message.fromLabel ?: message.from
val to = message.toLabel ?: message.to
return Row(
key = message.id,
title = "$from \u2192 $to",
description = message.body,
icon = avatars.icon(message.from),
badges = listOf(ActiveListBadge(message.type)),
from = message.from,
fromLabel = message.fromLabel,
to = message.to,
toLabel = message.toLabel,
)
}

/**
* Fetches the full board history, oldest-first, independent of the paged [board] the dialog has
* loaded so far: it starts from a fresh newest page so messages posted after the dialog opened
Expand Down
Loading
Loading