fix(jetbrains): clear the Agents tab dot once the attention has been read

The dot mirrored the activity snapshot, and an error stays in that snapshot until its session runs again, so a failed session left the dot lit for good. AgentAttention now tracks which sessions the user has already seen: attention that is pending while the Agent Manager is on screen counts as read, because the rows carry the badge there. The dot is therefore re-evaluated on tab selection and tool window visibility as well as on activity, and a session that recovers and fails again lights it once more.
This commit is contained in:
kirillk
2026-08-25 11:00:20 -04:00
parent b629accd3d
commit f80d7d3b50
4 changed files with 87 additions and 17 deletions
+1 -1
View File
@@ -2,4 +2,4 @@
"@kilocode/kilo-jetbrains": patch
---
Show worktree session titles in regular weight, keep the account switcher hidden when a new worktree starts with a prompt, add new worktrees at the top of the Agent Manager list, keep the running indicator on worktree rows when a stopped session is resumed, and keep session card popups inside the visible session view.
Show worktree session titles in regular weight, keep the account switcher hidden when a new worktree starts with a prompt, add new worktrees at the top of the Agent Manager list, keep the running indicator on worktree rows when a stopped session is resumed, mark failed sessions on their worktree row, clear the Agents tab notification dot once the attention has been read, and keep session card popups inside the visible session view.
@@ -11,11 +11,12 @@ import ai.kilocode.client.agentManager.SidePanelKeys
import ai.kilocode.client.agentManager.SidePanelMode
import ai.kilocode.client.agentManager.applySidePanelMode
import ai.kilocode.client.agentManager.worktree.WorktreeController
import ai.kilocode.client.agentManager.AgentAttention
import ai.kilocode.client.agentManager.AgentManagerPanel
import ai.kilocode.client.agentManager.sessionAttentionNeeded
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.ui.AttentionDotIcon
import ai.kilocode.log.KiloLog
import ai.kilocode.rpc.dto.SessionActivityDto
import com.intellij.openapi.actionSystem.ActionGroup
import com.intellij.openapi.actionSystem.ActionManager
import com.intellij.openapi.actionSystem.DataProvider
@@ -27,6 +28,7 @@ import com.intellij.openapi.util.Disposer
import com.intellij.openapi.wm.ToolWindow
import com.intellij.openapi.wm.ToolWindowContentUiType
import com.intellij.openapi.wm.ToolWindowFactory
import com.intellij.openapi.wm.ex.ToolWindowManagerListener
import com.intellij.platform.project.projectIdOrNull
import com.intellij.openapi.wm.impl.content.ToolWindowContentUi
import com.intellij.ui.content.ContentManagerEvent
@@ -34,7 +36,6 @@ import com.intellij.ui.content.ContentManagerListener
import com.intellij.ui.content.ContentFactory
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.awt.BorderLayout
@@ -151,23 +152,40 @@ internal class KiloToolWindowSetupService(
agents()
agentManagerPanel.move(id, dir)
}
// Notification dot on the Agents tab: attention the user has not looked at yet. Having
// the tab on screen is the acknowledgement, so the dot has to be re-evaluated when the
// selected tab or the tool window visibility changes, not only when activity arrives.
val attention = AgentAttention()
var snapshot = emptyMap<String, SessionActivityDto>()
fun syncDot() {
val showing = toolWindow.isVisible && toolWindow.contentManager.selectedContent === agentContent
agentContent.icon = if (attention.update(snapshot, showing)) AttentionDotIcon else null
}
val listener = object : ContentManagerListener {
override fun selectionChanged(event: ContentManagerEvent) {
if (event.operation == ContentManagerEvent.ContentOperation.add && event.content === agentContent) {
agentManagerPanel.refresh()
}
syncDot()
}
}
toolWindow.contentManager.addContentManagerListener(listener)
Disposer.register(manager) { toolWindow.contentManager.removeContentManagerListener(listener) }
val windows = object : ToolWindowManagerListener {
override fun toolWindowShown(shown: ToolWindow) {
if (shown.id == toolWindow.id) syncDot()
}
}
project.messageBus.connect(manager).subscribe(ToolWindowManagerListener.TOPIC, windows)
toolWindow.contentManager.setSelectedContent(chatContent)
manager.newSession()
// Show a notification dot on the Agents tab whenever a worktree session needs attention.
val dot = cs.launch {
project.service<KiloSessionService>().activity.map(::sessionAttentionNeeded).collect { needed ->
project.service<KiloSessionService>().activity.collect { current ->
withContext(Dispatchers.Main) {
agentContent.icon = if (needed) AttentionDotIcon else null
snapshot = current
syncDot()
}
}
}
@@ -4,13 +4,28 @@ import ai.kilocode.rpc.dto.SessionActivityDto
import ai.kilocode.rpc.dto.SessionActivityKindDto
/**
* Whether any session in the activity snapshot is waiting on the user or has failed,
* i.e. the Agents tab should show a notification dot.
* Notification dot state for the Agents tab.
*
* The dot marks attention the user has not looked at yet. Sessions that need attention while the
* Agent Manager is on screen count as seen, because the rows already carry the badge there. That is
* what lets the dot clear for good: an error stays in the activity snapshot until its session runs
* again, so a dot driven by the snapshot alone would come back every time the user left the tab.
* A session that stops needing attention is forgotten again, so a later failure lights the dot.
*/
internal fun sessionAttentionNeeded(activity: Map<String, SessionActivityDto>): Boolean =
activity.values.any {
it.kind == SessionActivityKindDto.QUESTION ||
it.kind == SessionActivityKindDto.PLAN ||
it.kind == SessionActivityKindDto.PERMISSION ||
it.kind == SessionActivityKindDto.ERROR
internal class AgentAttention {
private var seen = emptySet<String>()
/** Whether the dot should be visible, where [showing] means the Agent Manager is on screen. */
fun update(activity: Map<String, SessionActivityDto>, showing: Boolean): Boolean {
val pending = activity.filterValues(::attention).keys
seen = if (showing) pending else seen intersect pending
return (pending - seen).isNotEmpty()
}
}
/** Whether a session is waiting on the user or has failed. */
private fun attention(item: SessionActivityDto): Boolean =
item.kind == SessionActivityKindDto.QUESTION ||
item.kind == SessionActivityKindDto.PLAN ||
item.kind == SessionActivityKindDto.PERMISSION ||
item.kind == SessionActivityKindDto.ERROR
@@ -15,13 +15,50 @@ class AgentAttentionTest {
SessionActivityKindDto.PERMISSION,
SessionActivityKindDto.ERROR,
)) {
assertTrue(sessionAttentionNeeded(mapOf("ses" to SessionActivityDto("/repo/wt", kind))), kind.name)
assertTrue(AgentAttention().update(activity(kind), showing = false), kind.name)
}
}
@Test
fun `running and empty do not light up the dot`() {
assertFalse(sessionAttentionNeeded(emptyMap()))
assertFalse(sessionAttentionNeeded(mapOf("ses" to SessionActivityDto("/repo/wt", SessionActivityKindDto.RUNNING))))
assertFalse(AgentAttention().update(emptyMap(), showing = false))
assertFalse(AgentAttention().update(activity(SessionActivityKindDto.RUNNING), showing = false))
}
@Test
fun `attention seen on screen stays clear after leaving the tab`() {
val attention = AgentAttention()
val errored = activity(SessionActivityKindDto.ERROR)
assertFalse(attention.update(errored, showing = true))
// The error sticks in the snapshot until the session runs again; the dot must not come back.
assertFalse(attention.update(errored, showing = false))
assertFalse(attention.update(errored, showing = false))
}
@Test
fun `attention arriving while the tab is hidden lights the dot`() {
val attention = AgentAttention()
attention.update(activity(SessionActivityKindDto.ERROR), showing = true)
val another = mapOf(
"ses_1" to SessionActivityDto("/repo/wt", SessionActivityKindDto.ERROR),
"ses_2" to SessionActivityDto("/repo/other", SessionActivityKindDto.QUESTION),
)
assertTrue(attention.update(another, showing = false))
}
@Test
fun `a session that recovers and fails again lights the dot again`() {
val attention = AgentAttention()
val errored = activity(SessionActivityKindDto.ERROR)
attention.update(errored, showing = true)
assertFalse(attention.update(emptyMap(), showing = false))
assertTrue(attention.update(errored, showing = false))
}
private fun activity(kind: SessionActivityKindDto) =
mapOf("ses_1" to SessionActivityDto("/repo/wt", kind))
}