diff --git a/.changeset/calm-strips-toggle.md b/.changeset/calm-strips-toggle.md new file mode 100644 index 000000000000..6e402c8628a1 --- /dev/null +++ b/.changeset/calm-strips-toggle.md @@ -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. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/BackgroundAgentStrip.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/BackgroundAgentStrip.kt index 900eba85fdc7..bc14b3e34e6f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/BackgroundAgentStrip.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/BackgroundAgentStrip.kt @@ -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. @@ -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") @@ -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 @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/Strip.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/Strip.kt index b3efffb601b8..8f5363d9b28d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/Strip.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/Strip.kt @@ -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 @@ -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 @@ -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() + private val watched = linkedSetOf() 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 @@ -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. */ @@ -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() + } + } + } } /** diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/BackgroundAgentStripStressTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/BackgroundAgentStripStressTest.kt index 7f0a399c7ae5..40ef98f54f84 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/BackgroundAgentStripStressTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/BackgroundAgentStripStressTest.kt @@ -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 @@ -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().size) + assertEquals(1, descendants(strip.rowComponent()).filterIsInstance().size) } // Settling back down to just the two stable rows must drop every churned row. @@ -47,8 +48,8 @@ class BackgroundAgentStripStressTest : BasePlatformTestCase() { } private fun compact(strip: BackgroundAgentStrip, title: String) = - descendants(strip.rowComponent()).filterIsInstance().firstOrNull { - it.accessibleContext.accessibleName == "Open background agent $title" + descendants(strip.rowComponent()).filterIsInstance().firstOrNull { + it.toolTipText == title } private fun descendants(root: Component): List = buildList { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/BackgroundAgentStripTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/BackgroundAgentStripTest.kt index 04d07542e4b7..21d34c46ed4f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/BackgroundAgentStripTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/BackgroundAgentStripTest.kt @@ -117,9 +117,10 @@ class BackgroundAgentStripTest : BasePlatformTestCase() { private fun compactControls(strip: BackgroundAgentStrip): List = descendants(strip.rowComponent()).filterIsInstance() - private fun compactAgents(strip: BackgroundAgentStrip): List = compactControls(strip).filter { - it.accessibleContext.accessibleName?.startsWith("Open background agent ") == true - } + private fun compactAgents(strip: BackgroundAgentStrip): List = + descendants(strip.rowComponent()).filterIsInstance().filter { + it.name == "background-agent-preview" + } private fun aggregate(strip: BackgroundAgentStrip, panel: JComponent): Component { var component: Component = strip.labelComponent() @@ -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() val strip = strip(onOpen = { session, _ -> opened.add(session) }) strip.update( @@ -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)) @@ -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().all { it.toolTipText == "Analyze APIs" }) } fun `test compact preview never displaces east actions`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/StripTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/StripTest.kt index 49053289c638..f8841ccc1b62 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/StripTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/StripTest.kt @@ -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 @@ -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 @@ -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.