diff --git a/.changeset/jetbrains-worktree-list-fixes.md b/.changeset/jetbrains-worktree-list-fixes.md index 7aa15c9a92..124e389648 100644 --- a/.changeset/jetbrains-worktree-list-fixes.md +++ b/.changeset/jetbrains-worktree-list-fixes.md @@ -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. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt index a44a7b0d10..7063e66513 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt @@ -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() + 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().activity.map(::sessionAttentionNeeded).collect { needed -> + project.service().activity.collect { current -> withContext(Dispatchers.Main) { - agentContent.icon = if (needed) AttentionDotIcon else null + snapshot = current + syncDot() } } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentAttention.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentAttention.kt index b4506b0080..b89840e269 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentAttention.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentAttention.kt @@ -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): 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() + + /** Whether the dot should be visible, where [showing] means the Agent Manager is on screen. */ + fun update(activity: Map, 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 diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentAttentionTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentAttentionTest.kt index e105475abc..1a34e60399 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentAttentionTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentAttentionTest.kt @@ -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)) }