Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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/calm-strips-toggle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---

Make background-agent previews toggle their strip and add transcript-style hover feedback to agent and todo strips.
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ import javax.swing.SwingUtilities
* Background subagent status strip: a collapsible one-line summary of running/finished background
* agents, matching [TodoStrip]'s chrome (see [Strip]). Reachable actions:
*
* - Clicking a row opens that agent's read-only transcript via [onOpen].
* - Clicking an expanded row opens that agent's read-only transcript via [onOpen].
* - "Stop" / "Stop all" cancel one or every running agent via [onCancel] / [onCancelAll].
* - "Dismiss" / "Clear finished" hide finished rows locally via [onDismiss] — this never deletes
* the child session or the backend job record.
Expand Down Expand Up @@ -94,7 +94,6 @@ class BackgroundAgentStrip(

init {
summary.add(preview.panel, BorderLayout.CENTER)
watch(preview.panel)
preview.applyStyle(style)
summary.toolTipText = KiloBundle.message("session.header.agents.toggle")
summary.accessibleContext.accessibleName = KiloBundle.message("session.header.agents.toggle")
Expand Down Expand Up @@ -380,20 +379,16 @@ class BackgroundAgentStrip(
}

private inner class Chip {
private var agent = BackgroundAgent("", "", null, BackgroundAgentStatus.RUNNING)
private var id = ""
private var slot: Int? = null
private var static = AgentAvatar.static(id, slot)
private var running = AgentAvatar.running(id, slot)
private val avatar = JBLabel()
private val label = JBLabel()
val area = HoverArea(Stack.horizontal(UiStyle.Gap.sm()).next(avatar).next(label)).apply {
action = { onOpen(agent.session, title(agent)) }
}
val area = Stack.horizontal(UiStyle.Gap.sm()).next(avatar).next(label)

@RequiresEdt
fun update(next: BackgroundAgent) {
agent = next
val color = avatarColor(next.session)
if (next.session != id || color != slot) {
id = next.session
Expand All @@ -403,7 +398,9 @@ class BackgroundAgentStrip(
}
avatar.icon = if (next.status == BackgroundAgentStatus.RUNNING) running else static
label.text = title(next)
area.tooltip(label.text, KiloBundle.message("session.header.agents.open", label.text))
area.name = "background-agent-preview"
listOf(area, avatar, label).forEach { it.toolTipText = label.text }
area.accessibleContext.accessibleName = KiloBundle.message("session.header.agents.toggle")
}

@RequiresEdt
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package ai.kilocode.client.session.ui.header

import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.layout.HAlign
import ai.kilocode.client.ui.layout.Stack
Expand All @@ -17,12 +18,19 @@ import java.awt.BorderLayout
import java.awt.Component
import java.awt.Cursor
import java.awt.Dimension
import java.awt.Graphics
import java.awt.Graphics2D
import java.awt.RenderingHints
import java.awt.event.ContainerAdapter
import java.awt.event.ContainerEvent
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import javax.swing.BoxLayout
import javax.swing.JComponent
import javax.swing.JPanel
import javax.swing.ScrollPaneConstants
import javax.swing.SwingUtilities
import javax.swing.ToolTipManager

/**
* Shared chrome for a collapsible, one-line-when-collapsed status strip in the session header: an
Expand Down Expand Up @@ -56,16 +64,28 @@ abstract class Strip : JPanel(), SessionEditorStyleTarget {
border = JBUI.Borders.empty(0, UiStyle.Gap.sm())
add(fallback, BorderLayout.WEST)
}
private val row = BorderLayoutPanel().apply {
isOpaque = false
private val row = Row().apply {
add(arrow, BorderLayout.WEST)
add(summary.align(HAlign.TRACK, VAlign.CENTER), BorderLayout.CENTER)
add(actions.align(HAlign.RIGHT, VAlign.CENTER), BorderLayout.EAST)
}
private val clickable = linkedSetOf<Component>()
private val watched = linkedSetOf<Component>()
private var body: JComponent? = null
private val click = object : MouseAdapter() {
override fun mouseClicked(event: MouseEvent) = toggle()
}
private val pointer = object : MouseAdapter() {
override fun mouseEntered(event: MouseEvent) = hover(true)

override fun mouseExited(event: MouseEvent) {
if (!inside(event)) hover(false)
}
}
private val nested = object : ContainerAdapter() {
override fun componentAdded(event: ContainerEvent) = watch(event.child)
override fun componentRemoved(event: ContainerEvent) = unwatch(event.child)
}

/** Todo bodies remain horizontal; background-agent rows opt into width-tracking vertical scroll. */
protected open val vertical = false
Expand All @@ -81,17 +101,53 @@ abstract class Strip : JPanel(), SessionEditorStyleTarget {
// A strip has nothing to show until its owner reports content, so it starts hidden and each
// subclass's update() turns it on via syncVisible.
isVisible = false
// `row` spans the full width, so the empty space beside the summary toggles too. The arrow,
// glyph, and label keep their own listeners so a click lands on them directly; Swing
// delivers a click only to the innermost listener, so this cannot toggle twice. Controls in
// [actions] own their listeners and are therefore never retargeted here.
listOf(row, summary, arrow, fallback, content, glyph, label).forEach(::watch)
// Bind the whole retained subtree so dynamically added agent previews behave like the rest
// of the strip. Existing controls keep their own click action, while hover still covers them.
watch(row)
}

private fun watch(component: Component) {
if (!watched.add(component)) return
// Swing installs ToolTipManager as a mouse listener for inert labels/panels with tooltips;
// that does not make them independent controls. Only a different listener owns the click.
if (component.mouseListeners.all { it === ToolTipManager.sharedInstance() }) {
component.cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
component.addMouseListener(click)
clickable.add(component)
}
component.addMouseListener(pointer)
if (component is java.awt.Container) {
component.addContainerListener(nested)
component.components.forEach(::watch)
}
}

/** Give a subclass-owned summary surface the strip's ordinary toggle behavior. */
protected fun watch(component: Component) {
component.cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
component.addMouseListener(click)
private fun unwatch(component: Component) {
if (!watched.remove(component)) return
if (clickable.remove(component)) {
component.removeMouseListener(click)
component.cursor = Cursor.getDefaultCursor()
}
component.removeMouseListener(pointer)
if (component is java.awt.Container) {
component.removeContainerListener(nested)
component.components.forEach(::unwatch)
}
}

private fun hover(value: Boolean) {
val before = row.background
row.isHovered = value
if (before.rgb != row.background.rgb) row.repaint()
}

private fun inside(event: MouseEvent): Boolean {
val point = SwingUtilities.convertPoint(event.component, event.point, row)
if (!row.contains(point)) return false
val pane = SwingUtilities.getRootPane(row)?.layeredPane ?: return true
val spot = SwingUtilities.convertPoint(event.component, event.point, pane)
val top = SwingUtilities.getDeepestComponentAt(pane, spot.x, spot.y) ?: return true
return SwingUtilities.isDescendingFrom(top, row)
}

/** Build the expanded body. Called at most once; the result is retained for the strip's lifetime. */
Expand Down Expand Up @@ -166,6 +222,31 @@ abstract class Strip : JPanel(), SessionEditorStyleTarget {
internal fun labelForeground() = label.foreground
internal fun bodyAttached(): Boolean = expanded()
internal fun bodyComponent(): JComponent? = body

private class Row : BorderLayoutPanel() {
var isHovered = false

override fun isOpaque(): Boolean = false

override fun getBackground() = if (isHovered) {
SessionUiStyle.View.Surface.headerHoverBgColor()
} else {
SessionUiStyle.View.Surface.headerBgColor()
}

override fun paintComponent(g: Graphics) {
super.paintComponent(g)
val canvas = g.create() as Graphics2D
try {
canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
canvas.color = background
val arc = JBUI.scale(SessionUiStyle.View.BLOCK_ARC)
canvas.fillRoundRect(0, 0, width, height, arc, arc)
} finally {
canvas.dispose()
}
}
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import ai.kilocode.client.ui.HoverArea
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import java.awt.Component
import java.awt.Container
import javax.swing.JComponent

/**
* Streaming-UI churn test for [BackgroundAgentStrip], per this plugin's stress + leak test
Expand Down Expand Up @@ -34,7 +35,7 @@ class BackgroundAgentStripStressTest : BasePlatformTestCase() {
assertSame(firstCompact, compact(strip, "Agent job1"))
assertSame(secondCompact, compact(strip, "Agent job2"))
assertEquals(agents.size, strip.rowCount())
assertEquals(agents.size + 1, descendants(strip.rowComponent()).filterIsInstance<HoverArea>().size)
assertEquals(1, descendants(strip.rowComponent()).filterIsInstance<HoverArea>().size)
}

// Settling back down to just the two stable rows must drop every churned row.
Expand All @@ -47,8 +48,8 @@ class BackgroundAgentStripStressTest : BasePlatformTestCase() {
}

private fun compact(strip: BackgroundAgentStrip, title: String) =
descendants(strip.rowComponent()).filterIsInstance<HoverArea>().firstOrNull {
it.accessibleContext.accessibleName == "Open background agent $title"
descendants(strip.rowComponent()).filterIsInstance<JComponent>().firstOrNull {
it.toolTipText == title
}

private fun descendants(root: Component): List<Component> = buildList {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,10 @@ class BackgroundAgentStripTest : BasePlatformTestCase() {
private fun compactControls(strip: BackgroundAgentStrip): List<HoverArea> =
descendants(strip.rowComponent()).filterIsInstance<HoverArea>()

private fun compactAgents(strip: BackgroundAgentStrip): List<HoverArea> = compactControls(strip).filter {
it.accessibleContext.accessibleName?.startsWith("Open background agent ") == true
}
private fun compactAgents(strip: BackgroundAgentStrip): List<JComponent> =
descendants(strip.rowComponent()).filterIsInstance<JComponent>().filter {
it.name == "background-agent-preview"
}

private fun aggregate(strip: BackgroundAgentStrip, panel: JComponent): Component {
var component: Component = strip.labelComponent()
Expand Down Expand Up @@ -247,7 +248,7 @@ class BackgroundAgentStripTest : BasePlatformTestCase() {
assertFalse(strip.expanded())
}

fun `test collapsed preview shows a fitting prefix and overflow expands the strip`() {
fun `test collapsed preview agents expand the strip without opening a session`() {
val opened = mutableListOf<String>()
val strip = strip(onOpen = { session, _ -> opened.add(session) })
strip.update(
Expand All @@ -274,8 +275,11 @@ class BackgroundAgentStripTest : BasePlatformTestCase() {
assertEquals("+2 more", label.text)
assertEquals("Show 2 more background agents", more.accessibleContext.accessibleName)

assertTrue(action(agents[0]).doAccessibleAction(0))
assertEquals(listOf("ses1"), opened)
click(agents[0])
assertTrue(opened.isEmpty())
assertTrue(strip.expanded())

click(strip.rowComponent())
assertFalse(strip.expanded())

assertTrue(action(more).doAccessibleAction(0))
Expand Down Expand Up @@ -350,7 +354,16 @@ class BackgroundAgentStripTest : BasePlatformTestCase() {

assertSame(before[1], after[0])
assertSame(before[0], after[1])
assertEquals("Open background agent First finished", after[1].accessibleContext.accessibleName)
assertEquals("First finished", after[1].toolTipText)
}

fun `test compact preview mirrors the title tooltip across the chip`() {
val strip = strip()
strip.update(listOf(agent("job1", BackgroundAgentStatus.RUNNING, title = "Analyze APIs")))

val chip = compactAgents(strip).single()

assertTrue(descendants(chip).filterIsInstance<JComponent>().all { it.toolTipText == "Analyze APIs" })
}

fun `test compact preview never displaces east actions`() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package ai.kilocode.client.session.ui.header

import ai.kilocode.client.session.ui.style.SessionUiStyle
import com.intellij.icons.AllIcons
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.ui.components.JBScrollPane
Expand All @@ -9,6 +10,9 @@ import java.awt.Cursor
import java.awt.event.MouseEvent
import javax.swing.JComponent
import javax.swing.JLabel
import javax.swing.JLayeredPane
import javax.swing.JPanel
import javax.swing.JRootPane
import javax.swing.ScrollPaneConstants
import javax.swing.SwingUtilities

Expand Down Expand Up @@ -160,10 +164,55 @@ class StripTest : BasePlatformTestCase() {
}
}

fun `test hovering any summary part paints the transcript header hover fill`() {
val strip = TestStrip()
val row = strip.rowComponent().also { it.setSize(400, 24) }

enter(strip.labelComponent())

assertEquals(SessionUiStyle.View.Surface.headerHoverBgColor().rgb, row.background.rgb)

exit(strip.labelComponent(), 10_000, 10_000)

assertEquals(SessionUiStyle.View.Surface.headerBgColor().rgb, row.background.rgb)
}

fun `test hover clears when an overlay covers the strip header`() {
val strip = TestStrip().also { it.reveal(true) }
val row = strip.rowComponent()
val pane = pane(strip)
enter(row)
assertEquals(SessionUiStyle.View.Surface.headerHoverBgColor().rgb, row.background.rgb)

pane.add(JPanel().apply { setBounds(0, 0, 200, 40) }, JLayeredPane.PALETTE_LAYER)
exit(row, 5, 5)

assertEquals(SessionUiStyle.View.Surface.headerBgColor().rgb, row.background.rgb)
}

private fun pane(strip: Strip): JLayeredPane {
val root = JRootPane()
root.setSize(200, 40)
root.contentPane.add(strip)
strip.setSize(200, 40)
strip.doLayout()
root.doLayout()
root.contentPane.doLayout()
return root.layeredPane
}

private fun click(component: Component) {
component.dispatchEvent(MouseEvent(component, MouseEvent.MOUSE_CLICKED, System.currentTimeMillis(), 0, 1, 1, 1, false))
}

private fun enter(component: Component) {
component.dispatchEvent(MouseEvent(component, MouseEvent.MOUSE_ENTERED, System.currentTimeMillis(), 0, 1, 1, 0, false))
}

private fun exit(component: Component, x: Int, y: Int) {
component.dispatchEvent(MouseEvent(component, MouseEvent.MOUSE_EXITED, System.currentTimeMillis(), 0, x, y, 0, false))
}

/**
* Minimal concrete [Strip]. `toggleNow`/`reveal` are declared here rather than on [Strip] so the
* production class carries no test-only seams — a subclass may call its protected members.
Expand Down
Loading