diff --git a/.changeset/jetbrains-worktree-list-fixes.md b/.changeset/jetbrains-worktree-list-fixes.md new file mode 100644 index 00000000000..83b48d0a9cf --- /dev/null +++ b/.changeset/jetbrains-worktree-list-fixes.md @@ -0,0 +1,5 @@ +--- +"@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, mark failed and waiting sessions on their worktree row and in session lists, keep the Agents tab notification dot up until every session that needs you is resolved, and keep session card popups inside the visible session view while pointing at their card. diff --git a/.changeset/jetbrains-worktree-pr-badge-clicks.md b/.changeset/jetbrains-worktree-pr-badge-clicks.md new file mode 100644 index 00000000000..30303e33ef0 --- /dev/null +++ b/.changeset/jetbrains-worktree-pr-badge-clicks.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Keep the PR badge in the JetBrains Agent Manager worktree list clickable and aligned with the rest of the row. diff --git a/.changeset/overlay-takes-hover.md b/.changeset/overlay-takes-hover.md new file mode 100644 index 00000000000..eca333f1903 --- /dev/null +++ b/.changeset/overlay-takes-hover.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Let session overlays such as the connection banner take the pointer over from the transcript beneath them, so a covered card no longer stays hovered or keeps its popup open behind the overlay. diff --git a/.changeset/plain-worktree-headers.md b/.changeset/plain-worktree-headers.md new file mode 100644 index 00000000000..ee268b38d64 --- /dev/null +++ b/.changeset/plain-worktree-headers.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Render Agent Manager worktree list labels in normal weight with quieter idle icons, tint monochrome row icons to the selection foreground while leaving status icons colored, and clear a deleted session's question/error status from the session list, worktree list, and tab attention dot. diff --git a/.gitignore b/.gitignore index 16f4034b7f6..86e7e67c9f9 100644 --- a/.gitignore +++ b/.gitignore @@ -59,6 +59,7 @@ tsconfig.tsbuildinfo .kilo/yarn.lock .kilo/node_modules .kilo/plans/*upstream-merge-report-*.md +**/.kilo/jetbrains.json .kilocode/.gitignore .kilocode/package.json .kilocode/package-lock.json diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendActivityManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendActivityManager.kt index d8bfd6d1d11..7cd72053882 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendActivityManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendActivityManager.kt @@ -89,7 +89,14 @@ class KiloBackendActivityManager( is ChatEventDto.Error -> event.sessionID?.let { errors.add(it) } is ChatEventDto.TurnOpen -> errors.remove(event.sessionID) is ChatEventDto.SessionIdle -> clear(event.sessionID) - is ChatEventDto.SessionStatusChanged -> if (event.status.type == "idle") clear(event.sessionID) + is ChatEventDto.SessionStatusChanged -> when (event.status.type) { + "idle" -> clear(event.sessionID) + // Work restarted, so whatever ended the previous turn (a Stop publishes + // MessageAbortedError) is stale. Not every resume path publishes a turn event, so + // busy has to clear the error itself. + "busy" -> errors.remove(event.sessionID) + else -> Unit + } else -> Unit } } @@ -115,8 +122,11 @@ class KiloBackendActivityManager( if (pending.values.any { it }) return SessionActivityKindDto.PLAN return SessionActivityKindDto.QUESTION } - if (id in errors) return SessionActivityKindDto.ERROR + // Live work outranks a past error: the status stream and the chat events are separate + // collectors, so a resumed session can go busy before the event that clears its error + // arrives, and the row must keep spinning instead of resting on the stale error. if (busy) return SessionActivityKindDto.RUNNING + if (id in errors) return SessionActivityKindDto.ERROR return null } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt index b339ab36eaf..7c783ea59c0 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt @@ -352,7 +352,7 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { val items = if (list.ok) managedWorktrees(parseWorktreeList(list.stdout)) else emptyList() val store = worktreeNameStore(items) ?: base.resolve(".kilo").resolve(WORKTREE_NAMES_FILE) val paths = worktreePaths(items).ifEmpty { listOf(path) } - appendWorktreeOrder(store, path, paths) + prependWorktreeOrder(store, path, paths) return CreateWorktreeResultDto(worktree = WorktreeDto(path, dir.fileName.toString(), branch, path)) } @@ -811,13 +811,12 @@ private fun syncWorktreeState(file: Path, paths: List): WorktreeState { return next } -private fun appendWorktreeOrder(file: Path, path: String, paths: List) { +private fun prependWorktreeOrder(file: Path, path: String, paths: List) { val state = readWorktreeState(file) val set = paths.toSet() - val order = state.worktreeOrder.filter { it in set && !samePath(it, path) } + - paths.filter { it !in state.worktreeOrder && !samePath(it, path) } + - path - writeWorktreeState(file, state.copy(worktreeOrder = order.distinct())) + val rest = state.worktreeOrder.filter { it in set && !samePath(it, path) } + + paths.filter { it !in state.worktreeOrder && !samePath(it, path) } + writeWorktreeState(file, state.copy(worktreeOrder = (listOf(path) + rest).distinct())) } private fun removeWorktreeState(file: Path, path: String) { diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendActivityManagerTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendActivityManagerTest.kt index 1a6d1f9f4ef..a06aa09b640 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendActivityManagerTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendActivityManagerTest.kt @@ -102,10 +102,8 @@ class KiloBackendActivityManagerTest { statuses.value = mapOf("ses_1" to SessionStatusDto("busy")) start() + // Turn ends on an error: the session goes idle but the error must stay visible. events.emit(ChatEventDto.Error("ses_1")) - await("ses_1", SessionActivityKindDto.ERROR) - - // Turn ends: session goes idle but the error must stay visible. statuses.value = mapOf("ses_1" to SessionStatusDto("idle")) events.emit(ChatEventDto.SessionIdle("ses_1")) await("ses_1", SessionActivityKindDto.ERROR) @@ -116,6 +114,35 @@ class KiloBackendActivityManagerTest { assertFalse("ses_1" in manager.activity.value) } + @Test + fun `busy outranks a pending error so a resumed session runs`() = runBlocking { + directories["ses_1"] = "/repo/wt" + start() + + // A Stop leaves the session errored and idle. + events.emit(ChatEventDto.Error("ses_1")) + await("ses_1", SessionActivityKindDto.ERROR) + + // Resumed: busy arrives before anything clears the error. + statuses.value = mapOf("ses_1" to SessionStatusDto("busy")) + + await("ses_1", SessionActivityKindDto.RUNNING) + } + + @Test + fun `busy status event clears a pending error`() = runBlocking { + directories["ses_1"] = "/repo/wt" + start() + + events.emit(ChatEventDto.Error("ses_1")) + await("ses_1", SessionActivityKindDto.ERROR) + + events.emit(ChatEventDto.SessionStatusChanged("ses_1", SessionStatusDto("busy"))) + + withTimeout(5_000) { manager.activity.first { "ses_1" !in it } } + assertFalse("ses_1" in manager.activity.value) + } + @Test fun `global error without session is ignored`() = runBlocking { directories["ses_1"] = "/repo/wt" diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt index dcdb5991ec2..dbaa975e970 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt @@ -318,15 +318,18 @@ class KiloWorktreeRpcApiImplTest { } @Test - fun `create records order so reload keeps creation order`() = runBlocking { + fun `create records newest worktree first so reload keeps it on top`() = runBlocking { initRepo() val first = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("zebra")).worktree) val second = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("alpha")).worktree) val listed = api.list(repo.toString()).worktrees.filter { !it.main } - assertEquals(listOf(first.path, second.path), listed.map { it.path }) - assertEquals(listOf(first.path, second.path), readWorktreeState(repo.resolve(".kilo").resolve("jetbrains.json")).worktreeOrder) + assertEquals(listOf(second.path, first.path), listed.map { it.path }) + assertEquals( + listOf(second.path, first.path), + readWorktreeState(repo.resolve(".kilo").resolve("jetbrains.json")).worktreeOrder, + ) } @Test 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 a44a7b0d108..5718081914b 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 @@ -163,7 +163,9 @@ internal class KiloToolWindowSetupService( toolWindow.contentManager.setSelectedContent(chatContent) manager.newSession() - // Show a notification dot on the Agents tab whenever a worktree session needs attention. + // Notification dot on the Agents tab: up for as long as any worktree session is waiting + // on the user or has failed. Viewing the tab must not clear it — only resolving the + // attention does, so the dot stays a reliable "something still needs you" signal. val dot = cs.launch { project.service().activity.map(::sessionAttentionNeeded).collect { needed -> withContext(Dispatchers.Main) { 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 b4506b00801..c5c13ede48e 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,8 +4,12 @@ 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. + * 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. + * + * The dot mirrors that state for as long as it lasts, across every worktree and session. Viewing the + * tab does not clear it: only resolving the attention does, by answering the prompt or running the + * session again. */ internal fun sessionAttentionNeeded(activity: Map): Boolean = activity.values.any { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt index fd8410e51f2..c575dcb7c39 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt @@ -35,6 +35,7 @@ import ai.kilocode.client.ui.list.ActiveListMetrics import ai.kilocode.client.ui.list.ActiveListReorder import ai.kilocode.client.ui.list.ActiveListSelection import ai.kilocode.client.ui.list.ActiveListSurface +import ai.kilocode.client.ui.list.ActiveListWeight import ai.kilocode.client.ui.list.activeListToolWindowBackground import ai.kilocode.client.vfs.KiloVfsManager import ai.kilocode.rpc.dto.RemoveWorktreeResultDto @@ -95,7 +96,11 @@ class AgentManagerPanel( private val group = ActionManager.getInstance().getAction("Kilo.Worktree.RowMenu") as? ActionGroup ?: DefaultActionGroup() private val list = ActiveList( KiloBundle.message("worktree.empty"), - cfg = ActiveListConfig(hoverActions = true), + cfg = ActiveListConfig( + hoverActions = true, + title = ActiveListWeight.PLAIN, + header = ActiveListWeight.PLAIN, + ), surface = ActiveListSurface.ToolWindow, showSearch = false, onCell = { _, _ -> }, @@ -494,6 +499,7 @@ class AgentManagerPanel( override val description: String get() = WorktreeTitle.fallback(dto.path) override val tooltip: String? get() = null override val icon = WorktreeIcons.forRow(progress != null, kind, dto.locked, current) + override val tinted: Boolean get() = WorktreeIcons.neutral(icon) override val section: String? get() = if (current) null else KiloBundle.message("worktree.section.local") override val search: String get() = listOfNotNull(dto.name, dto.branch, dto.path, dto.lockReason).joinToString(" ") private val customName: String? get() = WorktreeTitle.custom(dto.name, dto.path) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeActivity.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeActivity.kt index 8422c556bf2..63321fc5bef 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeActivity.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeActivity.kt @@ -1,32 +1,29 @@ package ai.kilocode.client.agentManager.worktree import ai.kilocode.client.session.SessionActivityKind +import ai.kilocode.client.session.toKind import ai.kilocode.rpc.dto.SessionActivityDto -import ai.kilocode.rpc.dto.SessionActivityKindDto internal fun aggregateWorktreeActivity( activity: Map, ): Map = activity.values .groupBy { normalize(it.directory) } - .mapValues { (_, items) -> items.map { kind(it.kind) }.minBy(::rank) } + .mapValues { (_, items) -> items.map { it.kind.toKind() }.minBy(::rank) } internal fun normalizeWorktreePath(path: String): String = normalize(path) private fun normalize(path: String): String = path.trimEnd('/') -private fun kind(kind: SessionActivityKindDto): SessionActivityKind = when (kind) { - SessionActivityKindDto.RUNNING -> SessionActivityKind.RUNNING - SessionActivityKindDto.QUESTION -> SessionActivityKind.QUESTION - SessionActivityKindDto.PLAN -> SessionActivityKind.PLAN - SessionActivityKindDto.PERMISSION -> SessionActivityKind.PERMISSION - SessionActivityKindDto.ERROR -> SessionActivityKind.ERROR -} - +/** + * Precedence for a worktree holding several sessions: anything waiting on the user first, then live + * work, then a session left in an error. Running beats error so one stopped session cannot hide the + * spinner of a sibling that is still working. + */ private fun rank(kind: SessionActivityKind): Int = when (kind) { SessionActivityKind.PERMISSION -> 0 SessionActivityKind.QUESTION -> 1 SessionActivityKind.PLAN -> 2 - SessionActivityKind.ERROR -> 3 - SessionActivityKind.RUNNING -> 4 + SessionActivityKind.RUNNING -> 3 + SessionActivityKind.ERROR -> 4 SessionActivityKind.LOGIN_REQUIRED -> 5 } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeController.kt index 2a7e8ff20a1..a0ba0f8fb4c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeController.kt @@ -89,7 +89,7 @@ class WorktreeController( edt { val main = result.worktrees.firstOrNull { it.main } val extra = result.worktrees.filter { !it.main } - val rows = extra + pending.values + val rows = pending.values.toList().asReversed() + extra current = main model.replaceAll(rows) cache().putAll(rows) @@ -121,7 +121,7 @@ class WorktreeController( edt { pending[temp.id] = temp tasks[temp.id] = KiloBundle.message("worktree.progress.creating") - model.add(temp) + model.add(0, temp) onSelect?.invoke(temp.id) } cs.launch { @@ -136,7 +136,7 @@ class WorktreeController( edt { pending[temp.id] = temp tasks[temp.id] = KiloBundle.message("worktree.progress.creating") - model.add(temp) + model.add(0, temp) onSelect?.invoke(temp.id) } cs.launch { @@ -157,7 +157,7 @@ class WorktreeController( tasks.remove(temp.id) val idx = model.getElementIndex(temp) if (created != null) { - if (idx >= 0) model.setElementAt(created, idx) else model.add(created) + if (idx >= 0) model.setElementAt(created, idx) else model.add(0, created) cache().put(created) prompt?.let { service().put(created.path, it) } onSelect?.invoke(created.id) @@ -225,7 +225,7 @@ class WorktreeController( val temp = WorktreeDto("pending:$branch:${System.nanoTime()}", branch, branch, "pending:$branch") pending[temp.id] = temp tasks[temp.id] = label(MoveStage.CAPTURING) - model.add(temp) + model.add(0, temp) onSelect?.invoke(temp.id) cs.launch { var stage = MoveStage.CAPTURING @@ -243,7 +243,7 @@ class WorktreeController( tasks.remove(temp.id) val worktree = event.worktree ?: return@edt val idx = model.getElementIndex(temp) - if (idx >= 0) model.setElementAt(worktree, idx) else model.add(worktree) + if (idx >= 0) model.setElementAt(worktree, idx) else model.add(0, worktree) cache().put(worktree) // Queue the forked session for the editor the selection is about to // open; the tab's identity stays the worktree path alone. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeIcons.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeIcons.kt index 5fe84fbd5bf..d34b9fa8dec 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeIcons.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeIcons.kt @@ -21,9 +21,9 @@ internal object WorktreeIcons { /** * Leading icon for a worktree row. At rest the row shows what it is — the local machine, a locked - * checkout, or a branch checkout — while a running or waiting session takes the slot over so the - * list still surfaces activity at a glance. An operation on the row ([busy]) outranks all of it, - * and an errored session falls back to the resting glyph instead of shouting in the leading slot. + * checkout, or a branch checkout — while a running, waiting or failed session takes the slot over + * so the list still surfaces activity at a glance. An operation on the row ([busy]) outranks all + * of it. */ fun forRow( busy: Boolean, @@ -37,12 +37,16 @@ internal object WorktreeIcons { SessionActivityKind.QUESTION, SessionActivityKind.PERMISSION, SessionActivityKind.PLAN, - SessionActivityKind.LOGIN_REQUIRED -> kind.icon() - SessionActivityKind.ERROR, null -> when { + SessionActivityKind.LOGIN_REQUIRED, + SessionActivityKind.ERROR -> kind.icon() + null -> when { current -> local locked -> this.locked else -> branch } } } + + /** The monochrome at-rest glyphs that follow the row text color; status icons are excluded. */ + fun neutral(icon: Icon?): Boolean = icon === local || icon === locked || icon === branch } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanel.kt index d87cb64c021..234f3ad5969 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanel.kt @@ -23,6 +23,7 @@ import ai.kilocode.client.ui.list.ActiveListMenu import ai.kilocode.client.ui.list.ActiveListRowHeight import ai.kilocode.client.ui.list.ActiveListSelection import ai.kilocode.client.ui.list.ActiveListSurface +import ai.kilocode.client.ui.list.ActiveListWeight import ai.kilocode.client.ui.list.activeListToolWindowBackground import ai.kilocode.client.ui.layout.Stack import ai.kilocode.client.vfs.KiloVfsManager @@ -95,6 +96,7 @@ class WorktreeSessionEditorPanel( description = false, selection = ListSelectionModel.MULTIPLE_INTERVAL_SELECTION, hoverActions = true, + title = ActiveListWeight.PLAIN, ), surface = ActiveListSurface.ToolWindow, showSearch = false, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatsView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatsView.kt index 6dfabc84cc4..68671254350 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatsView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatsView.kt @@ -22,16 +22,24 @@ import java.awt.BorderLayout import java.awt.Component import java.awt.Container import java.awt.Cursor -import java.awt.Dimension import java.awt.event.MouseAdapter import java.awt.event.MouseEvent import javax.swing.Icon import javax.swing.JPanel +/** + * The trailing ahead/behind/diff and PR badges of a worktree row. + * + * Uses a real layout manager on purpose: a `null` layout resolves min/preferred size through the + * peer, which reports the component's *current* size. Inside the list this view is a single render + * stamp reused for every row, and [Stack] and [ai.kilocode.client.ui.layout.Align] clamp a child's + * preferred width into its `[min, max]` range - so a peer-reported minimum would carry the previous + * row's width into the next row's layout and drift the badges off their hit regions. + */ internal class WorktreeStatsView( openDiff: (() -> Unit)? = null, fill: Boolean = true, -) : JPanel(null) { +) : JPanel(BorderLayout()) { companion object { private val UP: Icon = IconLoader.getIcon("/icons/arrow-up.svg", WorktreeStatsView::class.java) private val DOWN: Icon = IconLoader.getIcon("/icons/arrow-down-to-line.svg", WorktreeStatsView::class.java) @@ -51,11 +59,10 @@ internal class WorktreeStatsView( // it is always the rightmost element. private val row = Stack.horizontal(UiStyle.Gap.md()).next(changeHit).next(prHit) private var url: String? = null - private var stats: WorktreeStatsDto? = null - private var pull: WorktreePrDto? = null + private var state: State? = null init { - add(row) + add(row, BorderLayout.CENTER) changeHit.act = openDiff prHit.act = { url?.let(BrowserUtil::browse) } diff.toolTipText = KiloBundle.message("worktree.stats.tooltip", 0, 0, 0, 0) @@ -83,21 +90,29 @@ internal class WorktreeStatsView( } fun update(stats: WorktreeStatsDto?, pull: WorktreePrDto?) { - if (this.stats == stats && this.pull == pull) return - this.stats = stats - this.pull = pull - sync(stats, pull?.let { ActiveListBadge("#${it.number}", style(it.state)) }, pull?.url, pull?.let(::prTooltip)) + sync( + State( + stats, + pull?.let { ActiveListBadge("#${it.number}", style(it.state)) }, + pull?.url, + pull?.let(::prTooltip), + ), + ) } fun update(stats: WorktreeStatsDto?, badge: ActiveListBadge?, prTip: String? = badge?.text) { - if (this.stats == stats && pull == null && (pr.icon as? FilledBadgeIcon)?.text == badge?.text && prHit.tip == prTip) return - this.stats = stats - this.pull = null - sync(stats, badge, null, prTip) + sync(State(stats, badge, null, prTip)) } - private fun sync(stats: WorktreeStatsDto?, badge: ActiveListBadge?, link: String?, tip: String?) { - val s = stats ?: WorktreeStatsDto("") + /** + * Applies [next] unless it is already rendered. The memo key must cover everything this method + * writes: inside the list one instance renders every row, so a field left out of the key would + * carry another row's badge, tooltip, or visibility. + */ + private fun sync(next: State) { + if (state == next) return + state = next + val s = next.stats ?: WorktreeStatsDto("") behind.text = s.behind.toString() behind.toolTipText = KiloBundle.message("worktree.stats.behind.tooltip") behind.isVisible = s.behind > 0 @@ -112,12 +127,12 @@ internal class WorktreeStatsView( diff.toolTipText = changeTip changeHit.tip = changeTip changeHit.toolTipText = changeTip - url = link - pr.icon = badge?.let { FilledBadgeIcon(it.text, it.style) } - pr.toolTipText = tip - prHit.tip = tip - prHit.toolTipText = tip - pr.isVisible = badge != null + url = next.link + pr.icon = next.badge?.let { FilledBadgeIcon(it.text, it.style) } + pr.toolTipText = next.tip + prHit.tip = next.tip + prHit.toolTipText = next.tip + pr.isVisible = next.badge != null val changesVisible = behind.isVisible || ahead.isVisible || diff.isVisible changeHit.isVisible = changesVisible prHit.isVisible = pr.isVisible @@ -145,18 +160,6 @@ internal class WorktreeStatsView( if (comp is Container) comp.components.forEach { applyCursor(it, active) } } - override fun getPreferredSize(): Dimension { - val ins = insets - val size = row.preferredSize - return Dimension(size.width + ins.left + ins.right, size.height + ins.top + ins.bottom) - } - - override fun doLayout() { - val ins = insets - val size = row.preferredSize - row.setBounds(ins.left, ins.top, minOf(size.width, width - ins.left - ins.right), minOf(size.height, height - ins.top - ins.bottom)) - } - private fun count(icon: Icon) = JBLabel().apply { this.icon = icon iconTextGap = UiStyle.Gap.xs() @@ -165,6 +168,14 @@ internal class WorktreeStatsView( border = JBUI.Borders.empty() } + /** Everything [sync] renders, so a repeated row can be skipped without leaking stale state. */ + private data class State( + val stats: WorktreeStatsDto?, + val badge: ActiveListBadge?, + val link: String?, + val tip: String?, + ) + /** A badge wrapper the ActiveList hit-tests for clicks, cursor, and tooltip. */ private class HitRegion(override val cellId: String) : JPanel(BorderLayout()), ActiveListHitCell { var act: (() -> Unit)? = null diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt index 0d1615644f2..422859cd886 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt @@ -5,6 +5,7 @@ package ai.kilocode.client.app import ai.kilocode.log.ChatLogSummary import ai.kilocode.rpc.KiloSessionRpcApi import ai.kilocode.client.session.SessionActivityKind +import ai.kilocode.client.session.toKind import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.CloudSessionListDto import ai.kilocode.rpc.dto.ConfigUpdateDto @@ -34,10 +35,12 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch /** @@ -68,13 +71,21 @@ class KiloSessionService internal constructor( private val _sessions = MutableStateFlow>(emptyList()) val sessions: StateFlow> = _sessions.asStateFlow() - /** Live session status map from SSE events. */ + // Sessions deleted this run. The backend does not always emit a status/activity clear for a + // session left in a waiting or failed state, so a deleted question/error entry would otherwise + // linger and keep its badge on the session list, worktree list, and tab attention dot. Pruning + // it locally forces every derived status to re-evaluate the moment the delete resolves. + private val removed = MutableStateFlow>(emptySet()) + + /** Live session status map from SSE events, minus sessions deleted this run. */ val statuses: StateFlow> = - stream { statuses() }.stateIn(cs, SharingStarted.Eagerly, emptyMap()) + combine(stream { statuses() }, removed) { map, gone -> map - gone } + .stateIn(cs, SharingStarted.Eagerly, emptyMap()) - /** Live session activity map from backend global events. */ + /** Live session activity map from backend global events, minus sessions deleted this run. */ val activity: StateFlow> = - stream { activity() }.stateIn(cs, SharingStarted.Eagerly, emptyMap()) + combine(stream { activity() }, removed) { map, gone -> map - gone } + .stateIn(cs, SharingStarted.Eagerly, emptyMap()) /** * Session create/update/delete across every directory the CLI serves, including sessions @@ -109,10 +120,15 @@ class KiloSessionService internal constructor( } } - internal fun activitySnapshot(): Map = - statuses.value - .filterValues { it.type == "busy" } - .mapValues { SessionActivityKind.RUNNING } + /** + * Per-session activity for history and session lists. [activity] is the richer source — it also + * carries waiting and failed sessions, and it covers sessions that are not open — but it drops + * sessions whose directory the backend cannot resolve, so the busy statuses stay as a fallback. + */ + internal fun activitySnapshot(): Map { + val busy = statuses.value.filterValues { it.type == "busy" }.mapValues { SessionActivityKind.RUNNING } + return busy + activity.value.mapValues { it.value.kind.toKind() } + } suspend fun list(dir: String): SessionListDto { val result = call { list(dir) } @@ -159,6 +175,7 @@ class KiloSessionService internal constructor( log.info("${ChatLogSummary.sid(id)} kind=session delete=true dir=${ChatLogSummary.dir(dir)}") call { delete(id, dir) } log.info("${ChatLogSummary.sid(id)} kind=session delete=true ok=true dir=${ChatLogSummary.dir(dir)}") + removed.update { it + id } list(dir) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionActivityKind.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionActivityKind.kt index d82dae0e3bd..f7403c3f200 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionActivityKind.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionActivityKind.kt @@ -2,6 +2,7 @@ package ai.kilocode.client.session import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.ui.UiStyle +import ai.kilocode.rpc.dto.SessionActivityKindDto import javax.swing.Icon enum class SessionActivityKind { @@ -30,3 +31,15 @@ enum class SessionActivityKind { fun icon(): Icon = ActivityIcon.of(this) } + +/** + * The backend reports activity for every session it knows, open or not. LOGIN_REQUIRED has no DTO + * counterpart: it comes from live session UI state instead. + */ +internal fun SessionActivityKindDto.toKind(): SessionActivityKind = when (this) { + SessionActivityKindDto.RUNNING -> SessionActivityKind.RUNNING + SessionActivityKindDto.QUESTION -> SessionActivityKind.QUESTION + SessionActivityKindDto.PLAN -> SessionActivityKind.PLAN + SessionActivityKindDto.PERMISSION -> SessionActivityKind.PERMISSION + SessionActivityKindDto.ERROR -> SessionActivityKind.ERROR +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 83789ad5a65..8d9a07ff8e3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -461,7 +461,9 @@ class SessionUi( hostedInEditorTab = manager?.hostedInEditorTab == true, ) connection = ConnectionPanel(this, controller) - root.addOverlay(connection) { pane, child -> + // The banner reports a broken session, so it owns the pointer where it sits: the transcript + // under it must not stay hovered and keep a popup open behind it. + root.addOverlay(connection, blocks = true) { pane, child -> val size = child.preferredSize if (readonly) { val gap = SessionUiStyle.View.contentGap() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt index 0772c362721..7d42ce94551 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt @@ -2308,6 +2308,9 @@ class SessionController( private fun setControllerViewState(event: SessionControllerEvent.ViewChanged) { assertEdt() if (disposed) return + // A late empty history load must not re-show the empty screen after a prompt opened the + // transcript. + if (event is SessionControllerEvent.ViewChanged.ShowEmpty && model.showSession) return if (event is SessionControllerEvent.ViewChanged.ShowSession) openLocal() if (viewState == event) return fire(event) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupController.kt index 7995bb51295..38d9892c34f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupController.kt @@ -120,7 +120,7 @@ class HeaderPopupController(timers: UiTimerSource = UiTimers) : Disposable { if (!onHeader && !onPopup) return hideAll() val req = view.headerPopup() ?: return hideAll() val built = req.build() - place(req.anchor, built)?.let { open(req, built, it) } ?: hideAll() + place(view, req.anchor, built)?.let { open(req, built, it) } ?: hideAll() } @RequiresEdt @@ -131,6 +131,7 @@ class HeaderPopupController(timers: UiTimerSource = UiTimers) : Disposable { .setBorderColor(UiStyle.Balloon.border()) .setBorderInsets(UiStyle.Balloon.insets()) .setPointerSize(UiStyle.Balloon.pointer()) + .setCornerToPointerDistance(spot.distance) .setCornerRadius(UiStyle.Balloon.arc()) .setHideOnClickOutside(true) .setHideOnKeyOutside(true) @@ -160,14 +161,16 @@ class HeaderPopupController(timers: UiTimerSource = UiTimers) : Disposable { } /** - * Resolves the pointer target beside the session chat, sizing the body to the space available on - * the chosen side. Anchoring on the chat rather than the hovered row is what keeps the popup off - * the transcript instead of covering the row the user is reading. + * Resolves the pointer target beside [card], the collapsible view the popup belongs to, sizing the + * body to the space available on the chosen side and to the visible height of the chat. Pointing at + * the card rather than the hovered row keeps the popup off the transcript instead of covering the + * row the user is reading, and pointing at the card rather than the session edge keeps the balloon + * attached to the thing it describes. * * Returns null when the chat is not on screen yet, in which case there is nothing to sit beside. */ @RequiresEdt - private fun place(anchor: JComponent, built: HeaderPopupBody): Spot? { + private fun place(card: JComponent, anchor: JComponent, built: HeaderPopupBody): Spot? { val pane = SwingUtilities.getRootPane(anchor)?.layeredPane val chat = ComponentUtil.getParentOfType(SessionRootPanel::class.java, anchor) // A showing anchor implies every ancestor, including the chat, is showing and laid out. @@ -175,14 +178,19 @@ class HeaderPopupController(timers: UiTimerSource = UiTimers) : Disposable { val gap = UiStyle.Gap.pad() val insets = UiStyle.Balloon.insets() // The shadow is reserved on every side, so it counts twice on each axis. - val shadow = UiStyle.Balloon.shadow() * 2 - val chromeHeight = insets.top + insets.bottom + shadow - val bounds = Rectangle(pane.size) + val shadow = UiStyle.Balloon.shadow() + val chromeHeight = insets.top + insets.bottom + shadow * 2 + // The visible chat rect, not the whole panel: a session clipped by a short tool window or a + // scrolled editor tab must keep its popups inside the part the user can actually see. + val area = SwingUtilities.convertRectangle(chat, chat.visibleRect, pane) + if (area.isEmpty) return null + val rect = SwingUtilities.convertRectangle(card.parent, card.bounds, pane) val spot = HeaderPopupGeometry.beside( - pane = bounds, - chat = SwingUtilities.convertRectangle(chat.parent, chat.bounds, pane), + pane = Rectangle(pane.size), + card = rect, + view = area, fit = HeaderPopupFit( - chromeWidth = insets.left + insets.right + UiStyle.Balloon.pointer().height + shadow, + chromeWidth = insets.left + insets.right + UiStyle.Balloon.pointer().height + shadow * 2, chromeHeight = chromeHeight, gap = gap, maxWidth = JBUI.scale(SessionUiStyle.View.Popup.WIDE_MAX_WIDTH), @@ -191,11 +199,20 @@ class HeaderPopupController(timers: UiTimerSource = UiTimers) : Disposable { ) built.fitWithin(spot.maxWidth, spot.maxHeight) val row = SwingUtilities.convertPoint(anchor, Point(0, anchor.height / 2), pane) - val height = built.component.preferredSize.height + chromeHeight - return Spot(pane, Point(spot.x, HeaderPopupGeometry.centerY(bounds, row.y, height, gap)), spot.position) + val view = Rectangle(area.x, area.y + shadow, area.width, (area.height - shadow * 2).coerceAtLeast(0)) + val height = built.component.preferredSize.height + insets.top + insets.bottom + val aim = HeaderPopupGeometry.aim( + view = view, + card = rect, + y = row.y, + height = height, + gap = gap, + indent = UiStyle.Balloon.arc() + UiStyle.Balloon.pointer().width / 2, + ) + return Spot(pane, Point(spot.x, aim.y), spot.position, aim.distance) } - private class Spot(val pane: JComponent, val point: Point, val position: Balloon.Position) + private class Spot(val pane: JComponent, val point: Point, val position: Balloon.Position, val distance: Int) private companion object { const val SHOW_MS = 500 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupGeometry.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupGeometry.kt index 6a790e9b3d6..20b3ce80307 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupGeometry.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupGeometry.kt @@ -4,7 +4,7 @@ import com.intellij.openapi.ui.popup.Balloon import java.awt.Rectangle /** - * Where a header popup should sit relative to the session chat, and how large its body may be. + * Where a header popup should sit relative to its card, and how large its body may be. * * [x] is the pointer target in the same coordinate space the placement was computed in. */ @@ -30,41 +30,82 @@ internal data class HeaderPopupFit( val maxHeight: Int, ) +/** + * Vertical pointer target and the distance from the balloon top to that target. + */ +internal data class HeaderPopupAim(val y: Int, val distance: Int) + /** * Geometry for header popups. Pure functions so the side and fit rules are testable without a frame. * - * Header popups only ever sit beside the chat, never over it and never above or below it. The fit part + * Header popups only ever sit beside their card, never over it and never above or below it. The fit part * is not cosmetic: `BalloonImpl.show` silently re-points a balloon to `BELOW`/`ABOVE` when the * requested rectangle does not fit inside the layered pane, so a body that overflows its side would * land in exactly the placement we are avoiding. Capping the body keeps the requested position. */ internal object HeaderPopupGeometry { - /** Picks the side of [chat] with more room inside [pane] and the body box that fits there. */ - fun beside(pane: Rectangle, chat: Rectangle, fit: HeaderPopupFit): HeaderPopupPlacement { - val left = (chat.x - pane.x).coerceAtLeast(0) - val right = (pane.x + pane.width - (chat.x + chat.width)).coerceAtLeast(0) + /** + * Picks the side of [card] with more room inside [pane] and the body box that fits there. + * + * The pointer lands on the edge of [card], the collapsible view the popup belongs to, so the + * balloon reads as attached to that card instead of docked to the far edge of the session. Room + * is still measured against [pane]: a card is narrower than the session, and cards near the + * middle of a split editor have almost no room beside them inside the session itself. + * + * [view] is the visible session and only budgets height. Using [card] there would collapse the + * body, since a collapsed card header is a couple of rows tall. + */ + fun beside(pane: Rectangle, card: Rectangle, view: Rectangle, fit: HeaderPopupFit): HeaderPopupPlacement { + val left = (card.x - pane.x).coerceAtLeast(0) + val right = (pane.x + pane.width - (card.x + card.width)).coerceAtLeast(0) // Ties go right: it matches reading direction and the common tool-window-on-the-left setup. val useRight = right >= left val room = (if (useRight) right else left) - fit.chromeWidth - fit.gap return HeaderPopupPlacement( position = if (useRight) Balloon.Position.atRight else Balloon.Position.atLeft, - x = if (useRight) chat.x + chat.width else chat.x, + x = if (useRight) card.x + card.width else card.x, maxWidth = room.coerceIn(0, fit.maxWidth), - maxHeight = (pane.height - fit.gap * 2 - fit.chromeHeight).coerceIn(0, fit.maxHeight), + // Height is budgeted against the session, not the pane: the popup belongs to the session + // view, so it must not run past it into editor tabs or neighbouring tool windows. + maxHeight = (view.height - fit.gap * 2 - fit.chromeHeight).coerceIn(0, fit.maxHeight), ) } /** - * Vertical pointer target for a body of [height], preferring [y] but keeping the balloon inside - * [pane]. The balloon centres its body on the target, so an unclamped target near an edge would - * overflow and trigger the same re-pointing that [beside] avoids horizontally. + * Keeps the pointer on [card] while moving the balloon body into [view]. The returned [distance] + * is the value the platform uses as `cornerToPointerDistance`, which makes the body slide without + * moving the pointer target off the element it describes. */ - fun centerY(pane: Rectangle, y: Int, height: Int, gap: Int): Int { - val half = height / 2 - val top = pane.y + gap + half - val bottom = pane.y + pane.height - gap - half - if (bottom < top) return pane.y + pane.height / 2 - return y.coerceIn(top, bottom) + fun aim(view: Rectangle, card: Rectangle, y: Int, height: Int, gap: Int, indent: Int): HeaderPopupAim { + val hit = card.intersection(view) + if (hit.isEmpty) return fallback(view, height, gap, indent) + val pointer = clamp(y, hit.y + indent, hit.y + hit.height - indent) + val top = top(view, pointer, height, gap) + return HeaderPopupAim(y = pointer, distance = legal(pointer - top, height, indent)) + } + + private fun fallback(view: Rectangle, height: Int, gap: Int, indent: Int): HeaderPopupAim { + val y = view.y + view.height / 2 + val top = top(view, y, height, gap) + return HeaderPopupAim(y = y, distance = legal(y - top, height, indent)) + } + + private fun top(view: Rectangle, y: Int, height: Int, gap: Int): Int { + val min = view.y + gap + val max = view.y + view.height - gap - height + if (max < min) return view.y + (view.height - height) / 2 + return (y - height / 2).coerceIn(min, max) + } + + private fun legal(distance: Int, height: Int, indent: Int): Int { + val max = height - indent + if (max < indent) return height / 2 + return distance.coerceIn(indent, max) + } + + private fun clamp(value: Int, min: Int, max: Int): Int { + if (max < min) return min + (max - min) / 2 + return value.coerceIn(min, max) } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt index 20453d2f366..551f6db45a4 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt @@ -301,9 +301,21 @@ abstract class AbstractSessionPartView( } } + /** + * Whether the pointer is still on the row. Bounds alone are not enough: an overlay painted above + * the transcript (the connection banner, the modal blocker) owns the pointer while sitting inside + * the row's rectangle, and Swing stops delivering to the row without ever leaving it + * geometrically. Asking which component is topmost at that point treats a covered row as left, so + * the exit clears the hover instead of keeping the row lit — and its popup alive — under the + * overlay. + */ private fun inside(e: MouseEvent): Boolean { val point = SwingUtilities.convertPoint(e.component, e.point, row) - return row.contains(point) + if (!row.contains(point)) return false + val pane = SwingUtilities.getRootPane(row)?.layeredPane ?: return true + val spot = SwingUtilities.convertPoint(e.component, e.point, pane) + val top = SwingUtilities.getDeepestComponentAt(pane, spot.x, spot.y) ?: return true + return SwingUtilities.isDescendingFrom(top, row) } /** diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/LayeredOverlayPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/LayeredOverlayPanel.kt index a9aeedcf792..42b4a4b63fc 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/LayeredOverlayPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/LayeredOverlayPanel.kt @@ -7,12 +7,20 @@ import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.JBUI import com.intellij.util.ui.components.BorderLayoutPanel import java.awt.BorderLayout +import java.awt.Component import java.awt.Container import java.awt.Dimension +import java.awt.GraphicsEnvironment +import java.awt.MouseInfo +import java.awt.Point import java.awt.Rectangle +import java.awt.event.ComponentAdapter +import java.awt.event.ComponentEvent +import java.awt.event.MouseEvent import javax.swing.JComponent import javax.swing.JLayeredPane import javax.swing.JPanel +import javax.swing.SwingUtilities open class LayeredOverlayPanel( content: JPanel = BorderLayoutPanel(), @@ -32,6 +40,17 @@ open class LayeredOverlayPanel( open val blocker: Blocker get() = baseBlocker + // An overlay that starts covering the pointer takes the hover over from the content below it. + // Swing already stops delivering mouse events to a covered component, but it sends no exit when + // the cover appears or moves without the pointer moving, so the content would keep its hover — + // and any hover-driven popup — alive behind the overlay. + private val cover = object : ComponentAdapter() { + override fun componentShown(e: ComponentEvent) = takeOverHover() + override fun componentHidden(e: ComponentEvent) = takeOverHover() + override fun componentMoved(e: ComponentEvent) = takeOverHover() + override fun componentResized(e: ComponentEvent) = takeOverHover() + } + init { layout = null add(baseContent) @@ -41,10 +60,17 @@ open class LayeredOverlayPanel( add(baseBlocker) setLayer(baseBlocker, MODAL_LAYER) baseBlocker.isVisible = false + baseOverlay.cover = cover + baseBlocker.addComponentListener(cover) } - fun addOverlay(child: JComponent, bounds: (JPanel, JComponent) -> Rectangle) { - overlay.addOverlay(child, bounds) + /** + * Adds a floating child above the content. A child that [blocks] owns the pointer where it sits: + * it takes the hover over from the content beneath it, which a decoration painted for the content + * below (a hover affordance of the very row it sits on) must not do. + */ + fun addOverlay(child: JComponent, blocks: Boolean = false, bounds: (JPanel, JComponent) -> Rectangle) { + overlay.addOverlay(child, blocks, bounds) } @RequiresEdt @@ -89,6 +115,41 @@ open class LayeredOverlayPanel( } } + /** + * Hands the hover of the content under the pointer over to the overlay that now covers it. + * Deferred because the trigger can arrive mid-layout, while a hover handler is free to close a + * popup or re-lay out the card it belongs to. + */ + private fun takeOverHover() = SwingUtilities.invokeLater(::releaseHover) + + @RequiresEdt + private fun releaseHover() { + if (GraphicsEnvironment.isHeadless() || !isShowing) return + val point = MouseInfo.getPointerInfo()?.location ?: return + SwingUtilities.convertPointFromScreen(point, this) + releaseHover(point) + } + + /** Releases the hover of the content at [point], in this panel's coordinates, when covered. */ + @RequiresEdt + internal fun releaseHover(point: Point) { + if (!covered(point)) return + val local = SwingUtilities.convertPoint(this, point, content) + val below = SwingUtilities.getDeepestComponentAt(content, local.x, local.y) ?: return + val spot = SwingUtilities.convertPoint(this, point, below) + below.dispatchEvent( + MouseEvent(below, MouseEvent.MOUSE_EXITED, System.currentTimeMillis(), 0, spot.x, spot.y, 0, false), + ) + } + + /** Whether the blocker or a blocking overlay child sits above the content at [point]. */ + private fun covered(point: Point): Boolean { + if (!Rectangle(size).contains(point)) return false + if (blocker.isVisible) return true + val local = SwingUtilities.convertPoint(this, point, overlay) + return overlay.blocks(local.x, local.y) + } + override fun getPreferredSize(): Dimension { val w = listOf(content, overlay).maxOfOrNull { it.preferredSize.width } ?: 0 val h = listOf(content, overlay).maxOfOrNull { it.preferredSize.height } ?: 0 @@ -99,22 +160,32 @@ open class LayeredOverlayPanel( private val items = linkedMapOf Rectangle>() + private val blocking = linkedSetOf() + + /** Notified when a blocking child is shown, hidden, moved, or resized. */ + internal var cover: ComponentAdapter? = null + init { layout = null isOpaque = false } - fun addOverlay(child: JComponent, bounds: (JPanel, JComponent) -> Rectangle) { + fun addOverlay(child: JComponent, blocks: Boolean = false, bounds: (JPanel, JComponent) -> Rectangle) { items[child] = bounds + if (blocks) { + blocking.add(child) + cover?.let(child::addComponentListener) + } add(child) } - override fun contains(x: Int, y: Int): Boolean { - for (child in components) { - if (child.isVisible && child.bounds.contains(x, y) && child.contains(x - child.x, y - child.y)) return true - } - return false - } + override fun contains(x: Int, y: Int): Boolean = components.any { hits(it, x, y) } + + /** Whether a child that blocks the content beneath it covers ([x], [y]). */ + internal fun blocks(x: Int, y: Int): Boolean = blocking.any { hits(it, x, y) } + + private fun hits(child: Component, x: Int, y: Int): Boolean = + child.isVisible && child.bounds.contains(x, y) && child.contains(x - child.x, y - child.y) override fun doLayout() { items.forEach { (child, bounds) -> diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt index bce69427479..9698bf5e087 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt @@ -32,6 +32,8 @@ internal data class ActiveListMetrics( internal enum class ActiveListRowHeight { EQUAL, PREFERRED } +internal enum class ActiveListWeight { PLAIN, BOLD } + internal data class ActiveListConfig( val height: ActiveListRowHeight = ActiveListRowHeight.EQUAL, val description: Boolean = true, @@ -39,6 +41,12 @@ internal data class ActiveListConfig( val tooltip: Boolean = true, val selection: Int = ListSelectionModel.SINGLE_SELECTION, val hoverActions: Boolean = false, + /** Weight used for the primary row title. */ + val title: ActiveListWeight = ActiveListWeight.BOLD, + /** Weight used for section headers. */ + val header: ActiveListWeight = ActiveListWeight.BOLD, + /** Show a separator line above section headers, except above the first row. */ + val divider: Boolean = true, ) { companion object { val Equal = ActiveListConfig(ActiveListRowHeight.EQUAL) @@ -83,10 +91,10 @@ internal interface ActiveListHitCell { /** * A row in an [ActiveList]. Carries the display contract shared by settings pages, the worktree - * list, and the session history stack: a leading icon, a bold title with an inline [note], a - * secondary [description] line, inline [badges], optional right-aligned [trailing] text, and - * action [cells]. Action cells are shown only for the active focused selection unless - * [ActiveListCell.alwaysVisible] is true. + * list, and the session history stack: a leading icon, a title whose weight follows + * [ActiveListConfig.title] with an inline [note], a secondary [description] line, inline [badges], + * optional right-aligned [trailing] text, and action [cells]. Action cells are shown only for the + * active focused selection unless [ActiveListCell.alwaysVisible] is true. */ internal interface ActiveListItem { val key: String @@ -102,6 +110,12 @@ internal interface ActiveListItem { val tooltip: String? get() = description val doubleClick: String? get() = null val icon: Icon? get() = null + /** + * Recolor [icon] to the row foreground when the row is the focused selection. Enable it only for + * monochrome glyphs that should read as part of the highlighted text; leave it off for colored + * status icons (running, question, error) so they keep their own hue. + */ + val tinted: Boolean get() = false val section: String? get() = null val badges: List get() = emptyList() /** Right-aligned secondary text, such as a relative timestamp. */ @@ -236,6 +250,21 @@ internal fun activeListLayout(component: Component) { for (child in component.components) activeListLayout(child) } +/** + * Marks a rendered row and everything under it invalid. + * + * A list renderer is one component reused for every row, and it changes content without changing + * size. Swing caches each container's preferred/minimum size and - through + * [java.awt.Container.validate], the layout pass painting uses - skips subtrees that are still + * valid, so a row would otherwise be laid out with sizes measured for whichever row the renderer + * rendered before it. Invalidating the whole stamp keeps painting and the [activeListLayout] pass + * behind [activeListHits] on the same geometry. + */ +internal fun activeListInvalidate(component: Component) { + component.invalidate() + if (component is Container) for (child in component.components) activeListInvalidate(child) +} + private fun forEachHitCell(component: Component, action: (ActiveListHitCell) -> Unit) { fun visit(c: Component) { // Skip hidden subtrees so a badge left visible inside a hidden trailing panel is not diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt index 9c603785a2b..735f46a9399 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt @@ -12,9 +12,11 @@ import ai.kilocode.client.ui.layout.align import com.intellij.icons.AllIcons import com.intellij.ui.CollectionListModel import com.intellij.ui.GroupHeaderSeparator +import com.intellij.ui.RelativeFont import com.intellij.ui.SimpleColoredComponent import com.intellij.ui.SimpleTextAttributes import com.intellij.ui.components.JBLabel +import com.intellij.util.IconUtil import com.intellij.util.ui.EmptyIcon import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil @@ -197,14 +199,7 @@ internal class ActiveListRenderer( background = list.background top.background = list.background wrap.update(list, selected, active) - sep.caption = section - sep.setHideLine(index == 0) - top.isVisible = section != null - top.setPreferredSize(section?.let { - val height = sep.preferredSize.height - .coerceAtLeast(sep.getFontMetrics(sep.font).height + insets.top + insets.bottom) - Dimension(0, height + JBUI.scale(2)) - }) + syncHeader(section, index) if (value is ActiveListGap) { gap = true @@ -213,21 +208,25 @@ internal class ActiveListRenderer( glyph.isVisible = false wrap.update(list, false, false) wrap.setPreferredSize(Dimension(0, bodyHeight ?: value.height)) - top.invalidate() + activeListInvalidate(this) return this } gap = false layers.isVisible = true title.clear() - // Bold carries the row: the description under it and the icon beside it both render in the - // muted secondary color, so weight is what separates the two lines rather than color alone. - title.append(value.title, SimpleTextAttributes(SimpleTextAttributes.STYLE_BOLD, titleFg)) + // Bold carries most rows by default: the description under it and the icon beside it both + // render in the muted secondary color, so cfg.title separates the two lines when enabled. + val style = if (cfg.title == ActiveListWeight.BOLD) SimpleTextAttributes.STYLE_BOLD else SimpleTextAttributes.STYLE_PLAIN + title.append(value.title, SimpleTextAttributes(style, titleFg)) value.note?.takeIf { it.isNotBlank() }?.let { title.append(" $it", SimpleTextAttributes.GRAYED_ATTRIBUTES) } syncBadges(value) - icon.icon = value.icon + // A selected row paints its title in the selection foreground; recolor a tinted glyph to + // match so it reads as part of the highlighted text. Colored status icons opt out and keep + // their own hue. + icon.icon = value.icon?.let { if (active && value.tinted) IconUtil.colorize(it, fg, keepBrightness = false) else it } mark.isVisible = value.icon != null val note = if (cfg.description) value.description.orEmpty() else "" desc.text = note @@ -263,10 +262,30 @@ internal class ActiveListRenderer( pill.background = if (selected && list.isEnabled) UIUtil.getListBackground(true, active) else list.background val height = bodyHeight wrap.setPreferredSize(height?.let { Dimension(0, it) }) - top.invalidate() + // Neither the content mutations above nor setPreferredSize invalidate reliably: a same-size + // icon swap, an equal label text, or an explicit preferred size leave the tree valid, and a + // valid subtree keeps the sizes it was measured with for another row. + activeListInvalidate(this) return this } + private fun syncHeader(section: String?, index: Int) { + sep.caption = section + sep.setHideLine(!cfg.divider || index == 0) + val font = if (cfg.header == ActiveListWeight.BOLD) { + RelativeFont.BOLD.derive(sep.font) + } else { + RelativeFont.PLAIN.derive(sep.font) + } + if (sep.font != font) sep.font = font + top.isVisible = section != null + top.setPreferredSize(section?.let { + val height = sep.preferredSize.height + .coerceAtLeast(sep.getFontMetrics(sep.font).height + insets.top + insets.bottom) + Dimension(0, height + JBUI.scale(2)) + }) + } + override fun paintChildren(g: Graphics) { super.paintChildren(g) if (!gap) return diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktree-local.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktree-local.svg index c5481bc9297..d1ce9fbc689 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktree-local.svg +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktree-local.svg @@ -1,4 +1,4 @@ - - + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktree-local_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktree-local_dark.svg index d40fa8f60f4..8766d725434 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktree-local_dark.svg +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktree-local_dark.svg @@ -1,4 +1,4 @@ - - + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktreeBranch.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktreeBranch.svg index 58184b7a91b..453719a6b27 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktreeBranch.svg +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktreeBranch.svg @@ -1 +1 @@ - + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktreeBranch_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktreeBranch_dark.svg index 00357612820..0f09a7c6881 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktreeBranch_dark.svg +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktreeBranch_dark.svg @@ -1 +1 @@ - + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktreeLock.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktreeLock.svg index bf088c0803d..75e8439d11b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktreeLock.svg +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktreeLock.svg @@ -1 +1 @@ - + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktreeLock_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktreeLock_dark.svg index d679769eb60..9037685be8c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktreeLock_dark.svg +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktreeLock_dark.svg @@ -1 +1 @@ - + 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 e105475abc1..14946e648ba 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,29 @@ class AgentAttentionTest { SessionActivityKindDto.PERMISSION, SessionActivityKindDto.ERROR, )) { - assertTrue(sessionAttentionNeeded(mapOf("ses" to SessionActivityDto("/repo/wt", kind))), kind.name) + assertTrue(sessionAttentionNeeded(activity(kind)), 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(sessionAttentionNeeded(activity(SessionActivityKindDto.RUNNING))) } + + @Test + fun `one session needing attention lights the dot for the whole snapshot`() { + val mixed = mapOf( + "ses_running" to SessionActivityDto("/repo/a", SessionActivityKindDto.RUNNING), + "ses_failed" to SessionActivityDto("/repo/b", SessionActivityKindDto.ERROR), + ) + + assertTrue(sessionAttentionNeeded(mixed)) + // Only resolving it clears the dot, however often the state is re-evaluated. + assertTrue(sessionAttentionNeeded(mixed)) + assertFalse(sessionAttentionNeeded(mixed - "ses_failed")) + } + + private fun activity(kind: SessionActivityKindDto) = + mapOf("ses_1" to SessionActivityDto("/repo/wt", kind)) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt index 8bc0864ed9c..9c47e660c41 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt @@ -52,9 +52,13 @@ import com.intellij.openapi.vfs.VirtualFile import com.intellij.ui.SearchTextField import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.testFramework.replaceService +import com.intellij.ui.SimpleColoredComponent +import com.intellij.ui.SimpleTextAttributes import com.intellij.ui.components.JBList import com.intellij.ui.components.JBScrollPane import com.intellij.util.ui.UIUtil +import java.awt.Component +import java.awt.Container import java.awt.event.MouseEvent import java.awt.Point import javax.swing.JComponent @@ -100,13 +104,13 @@ class AgentManagerPanelTest : BasePlatformTestCase() { edt { controller.create("feature/y", null) } val list = edt { UIUtil.findComponentOfType(panel, JBList::class.java)!! } - val pendingId = edt { controller.model.getElementAt(controller.model.size - 1).id } + val pendingId = edt { controller.model.getElementAt(0).id } assertEquals(pendingId, edt { (list.selectedValue as ActiveListItem).key }) gate.complete(Unit) flush() - val created = edt { controller.model.getElementAt(controller.model.size - 1) } + val created = edt { controller.model.getElementAt(0) } assertEquals("feature/y", created.branch) assertEquals(created.id, edt { (list.selectedValue as ActiveListItem).key }) } @@ -184,6 +188,26 @@ class AgentManagerPanelTest : BasePlatformTestCase() { assertEquals(0, edt { scroll.viewportBorder.getBorderInsets(scroll).top }) } + fun `test worktree list renders row titles in plain weight`() { + rpc.listed += worktree("aardvark") + val controller = WorktreeController(service, project.basePath!!, coroutines.scope) + val panel = edt { AgentManagerPanel(testRootDisposable, controller, project) } + edt { controller.reload() } + flush() + + @Suppress("UNCHECKED_CAST") + val list = edt { UIUtil.findComponentOfType(panel, JBList::class.java)!! as JBList } + val title = edt { + val row = list.model.getElementAt(0) + val comp = list.cellRenderer.getListCellRendererComponent(list, row, 0, false, false) + components(comp).filterIsInstance().single() + } + val iter = title.iterator() + iter.next() + + assertEquals(SimpleTextAttributes.STYLE_PLAIN, iter.textAttributes.style) + } + fun `test clicking a worktree opens the worktree session editor`() { val item = WorktreeDto("/repo/.kilo/worktrees/feature-x", "feature-x", "feature/x", "${project.basePath!!}/.kilo/worktrees/feature-x") rpc.listed += item @@ -533,7 +557,7 @@ class AgentManagerPanelTest : BasePlatformTestCase() { assertEquals(emptyList(), row.badges) } - fun `test worktree row uses the branch icon for error activity`() { + fun `test worktree row uses the error icon for error activity`() { val item = WorktreeDto("/repo/.kilo/worktrees/feature-x", "feature-x", "feature/x", "/repo/.kilo/worktrees/feature-x") val activity = MutableStateFlow(mapOf( "ses_1" to SessionActivityDto(item.path, SessionActivityKindDto.ERROR), @@ -544,7 +568,7 @@ class AgentManagerPanelTest : BasePlatformTestCase() { edt { controller.reload() } flush() - assertSame(WorktreeIcons.branch, row(panel, 0).icon) + assertSame(SessionActivityKind.ERROR.icon(), row(panel, 0).icon) } fun `test idle worktree rows show the branch icon and the local row shows the monitor`() { @@ -778,10 +802,9 @@ class AgentManagerPanelTest : BasePlatformTestCase() { layout(view) edt { - val size = view.list.model.size - // Row 0 is the current (main) row; the last row is the pending create. + // Row 0 is the current (main) row; row 1 is the pending create. assertNull(view.pickable(rowCenter(view, 0))) - assertNull(view.pickable(rowCenter(view, size - 1))) + assertNull(view.pickable(rowCenter(view, 1))) } gate.complete(Unit) flush() @@ -852,6 +875,16 @@ class AgentManagerPanelTest : BasePlatformTestCase() { return edt { list.model.getElementAt(idx) as ActiveListItem } } + private fun components(root: Component): List { + val out = mutableListOf() + fun visit(item: Component) { + out += item + if (item is Container) item.components.forEach { visit(it) } + } + visit(root) + return out + } + private fun center(rect: java.awt.Rectangle) = Point(rect.x + rect.width / 2, rect.y + rect.height / 2) private fun pump() = pumpEdt() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeControllerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeControllerTest.kt index f1030f2022f..d798260f495 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeControllerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeControllerTest.kt @@ -102,6 +102,25 @@ class WorktreeControllerTest : BasePlatformTestCase() { assertEquals("feature/y", selected.last()) } + fun `test create prepends placeholder and created worktree`() { + rpc.listed += WorktreeDto("/repo/.kilo/worktrees/feature-x", "feature-x", "feature/x", "/repo/.kilo/worktrees/feature-x") + val gate = CompletableDeferred() + rpc.beforeCreate = { gate.await() } + val controller = controller() + controller.reload() + flush() + + ApplicationManager.getApplication().invokeAndWait { controller.create("feature/y", null) } + + assertEquals("feature/y", controller.model.getElementAt(0).branch) + assertTrue(controller.isPending(controller.model.getElementAt(0).id)) + gate.complete(Unit) + flush() + + assertEquals("feature/y", controller.model.getElementAt(0).branch) + assertFalse(controller.isPending(controller.model.getElementAt(0).id)) + } + fun `test create failure removes placeholder and reports the error`() { rpc.createResult = { CreateWorktreeResultDto(error = "boom") } val controller = controller() @@ -129,7 +148,7 @@ class WorktreeControllerTest : BasePlatformTestCase() { controller.reload() flush() - assertEquals(listOf("feature/x", "feature/y"), (0 until controller.model.size).map { controller.model.getElementAt(it).branch }) + assertEquals(listOf("feature/y", "feature/x"), (0 until controller.model.size).map { controller.model.getElementAt(it).branch }) assertTrue(controller.isPending(id)) gate.complete(Unit) flush() @@ -493,7 +512,7 @@ class WorktreeControllerTest : BasePlatformTestCase() { } } - fun `test worktree row icons show only while running or waiting`() { + fun `test worktree row icons show while running, waiting or failed`() { assertSame( WorktreeIcons.spinner, WorktreeIcons.forRow(busy = true, kind = SessionActivityKind.RUNNING), @@ -510,7 +529,10 @@ class WorktreeControllerTest : BasePlatformTestCase() { SessionActivityKind.PLAN.icon(), WorktreeIcons.forRow(busy = false, kind = SessionActivityKind.PLAN), ) - assertSame(WorktreeIcons.branch, WorktreeIcons.forRow(busy = false, kind = SessionActivityKind.ERROR)) + assertSame( + SessionActivityKind.ERROR.icon(), + WorktreeIcons.forRow(busy = false, kind = SessionActivityKind.ERROR), + ) assertSame(WorktreeIcons.branch, WorktreeIcons.forRow(busy = false, kind = null)) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeIconsTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeIconsTest.kt index 72a98c1dde6..37b9396f0b7 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeIconsTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeIconsTest.kt @@ -23,13 +23,15 @@ class WorktreeIconsTest : BasePlatformTestCase() { fun `test resting row icons carry the muted palette in both themes`() { for (name in listOf("worktreeBranch", "worktreeLock", "worktree-local")) { - // The secondary New UI greys, which are also what Label.infoForeground resolves to, so a - // resting glyph sits at the weight of the description line under it rather than the title. - val light = svg(name).replace("#818594", "GLYPH") - val dark = svg("${name}_dark").replace("#6F737A", "GLYPH") + // The tertiary New UI greys: a resting glyph only says what the checkout is, so it sits a + // step quieter than the secondary grey the description line under it uses. + val light = svg(name).replace("#A8ADBD", "GLYPH") + val dark = svg("${name}_dark").replace("#9DA0A8", "GLYPH") assertFalse("$name still uses a primary grey", light.contains("#6C707E")) assertFalse("${name}_dark still uses a primary grey", dark.contains("#CED0D6")) + assertFalse("$name still uses the secondary grey", light.contains("#818594")) + assertFalse("${name}_dark still uses the secondary grey", dark.contains("#6F737A")) // Recoloring must be the only difference: the loader animates between the two. assertEquals("$name geometry drifted from its dark variant", light, dark) } @@ -76,12 +78,13 @@ class WorktreeIconsTest : BasePlatformTestCase() { assertSame(WorktreeIcons.local, WorktreeIcons.forRow(busy = false, current = true)) } - fun `test errored session falls back to the resting glyph`() { - assertSame(WorktreeIcons.branch, WorktreeIcons.forRow(busy = false, kind = SessionActivityKind.ERROR)) - assertSame( - WorktreeIcons.local, - WorktreeIcons.forRow(busy = false, kind = SessionActivityKind.ERROR, current = true), - ) + fun `test errored session shows the error glyph over the resting one`() { + val error = SessionActivityKind.ERROR.icon() + assertSame(error, WorktreeIcons.forRow(busy = false, kind = SessionActivityKind.ERROR)) + assertSame(error, WorktreeIcons.forRow(busy = false, kind = SessionActivityKind.ERROR, current = true)) + assertSame(error, WorktreeIcons.forRow(busy = false, kind = SessionActivityKind.ERROR, locked = true)) + // An operation on the row still outranks it. + assertSame(WorktreeIcons.spinner, WorktreeIcons.forRow(busy = true, kind = SessionActivityKind.ERROR)) } fun `test activity outranks the resting glyph on the local row`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeActivityTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeActivityTest.kt index 02b4292854e..5270e31c182 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeActivityTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeActivityTest.kt @@ -32,12 +32,12 @@ class WorktreeActivityTest { } @Test - fun `error outranks running but yields to interactive prompts`() { - val errorOverRunning = aggregateWorktreeActivity(mapOf( + fun `running outranks a sibling error but yields to interactive prompts`() { + val runningOverError = aggregateWorktreeActivity(mapOf( "ses_run" to SessionActivityDto("/repo/wt", SessionActivityKindDto.RUNNING), "ses_error" to SessionActivityDto("/repo/wt", SessionActivityKindDto.ERROR), )) - assertEquals(SessionActivityKind.ERROR, errorOverRunning["/repo/wt"]) + assertEquals(SessionActivityKind.RUNNING, runningOverError["/repo/wt"]) val questionOverError = aggregateWorktreeActivity(mapOf( "ses_error" to SessionActivityDto("/repo/wt", SessionActivityKindDto.ERROR), diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanelTest.kt index 9dbc014c804..53381d7858b 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanelTest.kt @@ -35,6 +35,8 @@ import com.intellij.openapi.ui.TestDialogManager import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.ui.OnePixelSplitter import com.intellij.ui.SearchTextField +import com.intellij.ui.SimpleColoredComponent +import com.intellij.ui.SimpleTextAttributes import com.intellij.ui.components.JBList import com.intellij.ui.components.JBScrollPane import com.intellij.util.ui.UIUtil @@ -236,6 +238,26 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() { assertEquals("new", edt { (list.selectedValue as ActiveListItem).key }) } + fun `test session row title uses regular font`() { + rpc.listed += session("ses_1", nowSeconds()) + edt { controller.reload() } + flush() + + val style = edt { + @Suppress("UNCHECKED_CAST") + val list = UIUtil.findComponentOfType(panel, JBList::class.java)!! as JBList + val row = list.model.getElementAt(0) as ActiveListItem + val comp = list.cellRenderer.getListCellRendererComponent(list, row, 0, true, true) + val title = components(comp).filterIsInstance().single() + val iter = title.iterator() + assertTrue(iter.hasNext()) + iter.next() + iter.textAttributes.style + } + + assertEquals(SimpleTextAttributes.STYLE_PLAIN, style) + } + fun `test running session row shows activity badge without leading icon`() { manager.kinds = mapOf("ses_1" to SessionActivityKind.RUNNING) val session = session("ses_1", nowSeconds()) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloSessionServiceTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloSessionServiceTest.kt index 715f1079464..dd1c2c89f54 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloSessionServiceTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloSessionServiceTest.kt @@ -1,9 +1,13 @@ package ai.kilocode.client.app +import ai.kilocode.client.session.SessionActivityKind import ai.kilocode.client.testing.FakeSessionRpcApi import ai.kilocode.client.testing.TestLog import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.SessionActivityDto +import ai.kilocode.rpc.dto.SessionActivityKindDto import ai.kilocode.rpc.dto.SessionDto +import ai.kilocode.rpc.dto.SessionStatusDto import ai.kilocode.rpc.dto.SessionTimeDto import com.intellij.testFramework.fixtures.BasePlatformTestCase import kotlinx.coroutines.CoroutineScope @@ -11,6 +15,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.toList @@ -139,6 +144,41 @@ class KiloSessionServiceTest : BasePlatformTestCase() { assertTrue(log.messages.joinToString("\n"), log.messages.any { it.contains("route=client-events stop=true failed message=stream failed") }) } + fun `test activity snapshot carries every kind the backend reports`() = runBlocking(Dispatchers.Default) { + // A busy session the backend cannot place in a directory, so only the status map has it. + rpc.statuses.value = mapOf("ses_busy" to SessionStatusDto("busy")) + rpc.activity.value = mapOf( + "ses_failed" to SessionActivityDto("/repo/wt", SessionActivityKindDto.ERROR), + "ses_asking" to SessionActivityDto("/repo/wt", SessionActivityKindDto.QUESTION), + ) + service.activity.first { it.isNotEmpty() } + + assertEquals( + mapOf( + "ses_busy" to SessionActivityKind.RUNNING, + "ses_failed" to SessionActivityKind.ERROR, + "ses_asking" to SessionActivityKind.QUESTION, + ), + service.activitySnapshot(), + ) + } + + fun `test deleting a session prunes its lingering activity and status entries`() = runBlocking(Dispatchers.Default) { + rpc.statuses.value = mapOf("ses_asking" to SessionStatusDto("busy")) + rpc.activity.value = mapOf( + "ses_asking" to SessionActivityDto("/repo/wt", SessionActivityKindDto.QUESTION), + "ses_failed" to SessionActivityDto("/repo/wt", SessionActivityKindDto.ERROR), + ) + service.activity.first { it.size == 2 } + + // The backend keeps reporting the question/error for a deleted session, so the entry must be + // pruned locally or the badge lingers on every derived surface. + service.deleteSession("ses_asking", "/repo/wt") + service.activity.first { "ses_asking" !in it } + + assertEquals(mapOf("ses_failed" to SessionActivityKind.ERROR), service.activitySnapshot()) + } + private fun session(id: String, title: String) = SessionDto( id = id, projectID = "prj", diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt index 4429858362e..9b2afc4400a 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt @@ -932,6 +932,22 @@ class SessionUiLayoutTest : SessionUiTestBase() { assertFalse(overlay.isVisible) } + fun `test account overlay stays hidden when prompt races empty history load`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = ProfileDto(email = "user@example.com")) + val gate = CompletableDeferred() + rpc.historyGate = gate + ui = newUi(id = "ses_test") + + ApplicationManager.getApplication().invokeAndWait { + controller().prompt("hello") + } + gate.complete(Unit) + settle() + + val overlay = find(ui) + assertFalse(overlay.isVisible) + } + fun `test non-empty explicit session does not show overlay`() { rpc.history.add(MessageWithPartsDto(message("msg1"), emptyList())) ui = newUi(id = "ses_test") diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/HistoryLoadingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/HistoryLoadingTest.kt index e61bee996e2..0a3206b1012 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/HistoryLoadingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/HistoryLoadingTest.kt @@ -11,6 +11,7 @@ import ai.kilocode.rpc.dto.MessageTimeDto import ai.kilocode.rpc.dto.MessageWithPartsDto import ai.kilocode.rpc.dto.ModelDto import ai.kilocode.rpc.dto.ProviderDto +import kotlinx.coroutines.CompletableDeferred class HistoryLoadingTest : SessionControllerTestBase() { @@ -91,6 +92,25 @@ class HistoryLoadingTest : SessionControllerTestBase() { ) } + fun `test prompt during history load keeps the session view`() { + val gate = CompletableDeferred() + rpc.historyGate = gate + + val c = controller("ses_test") + val events = collect(c) + edt { c.prompt("hello") } + gate.complete(Unit) + flush() + + assertControllerEvents(""" + AccountOverlayChanged hide + AppChanged + WorkspaceChanged + ViewChanged progress + ViewChanged session + """, events) + } + fun `test loaded history derives agent from latest message`() { appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, config = ConfigDto(model = "kilo/gpt-5")) projectRpc.state.value = workspaceReady(agents = agents(), default = "plan") diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupGeometryTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupGeometryTest.kt index 2c4dec004fc..39da002f2e5 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupGeometryTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupGeometryTest.kt @@ -13,28 +13,44 @@ class HeaderPopupGeometryTest { const val GAP = 10 const val CAP = 700 const val CAP_HEIGHT = 450 + const val INDENT = 16 } @Test - fun `chat on the left points right`() { + fun `card on the left points right`() { // Tool window on the left: the editor area to its right is the roomier side. - val spot = beside(chat = Rectangle(0, 0, 300, 1000)) + val spot = beside(card = Rectangle(0, 0, 300, 40)) assertEquals(Balloon.Position.atRight, spot.position) assertEquals(300, spot.x) } @Test - fun `chat on the right points left`() { - val spot = beside(chat = Rectangle(1700, 0, 300, 1000)) + fun `card on the right points left`() { + val spot = beside(card = Rectangle(1700, 0, 300, 40)) assertEquals(Balloon.Position.atLeft, spot.position) assertEquals(1700, spot.x) } + @Test + fun `the pointer lands on the card edge, not the session edge`() { + // Left-docked chat: cards are inset from the session, so the balloon hugs the card at 760 + // rather than docking to the session edge at 800. + val spot = HeaderPopupGeometry.beside( + pane = Rectangle(0, 0, 2000, 1000), + card = Rectangle(60, 300, 700, 40), + view = Rectangle(0, 0, 800, 1000), + fit = fit(), + ) + + assertEquals(Balloon.Position.atRight, spot.position) + assertEquals(760, spot.x) + } + @Test fun `side with more room wins even when both sides fit`() { - val spot = beside(chat = Rectangle(1200, 0, 300, 1000)) + val spot = beside(card = Rectangle(1200, 0, 300, 40)) // Left room is 1200, right room is 500. assertEquals(Balloon.Position.atLeft, spot.position) @@ -43,14 +59,14 @@ class HeaderPopupGeometryTest { @Test fun `equal room points right`() { - val spot = beside(chat = Rectangle(850, 0, 300, 1000)) + val spot = beside(card = Rectangle(850, 0, 300, 40)) assertEquals(Balloon.Position.atRight, spot.position) } @Test fun `body is capped to the free space on the chosen side`() { - val spot = beside(chat = Rectangle(0, 0, 1800, 1000)) + val spot = beside(card = Rectangle(0, 0, 1800, 40)) // 200 free on the right, minus chrome and gap. assertEquals(200 - CHROME - GAP, spot.maxWidth) @@ -58,14 +74,14 @@ class HeaderPopupGeometryTest { @Test fun `body is capped to the shared max when the side is roomy`() { - val spot = beside(chat = Rectangle(0, 0, 300, 1000)) + val spot = beside(card = Rectangle(0, 0, 300, 40)) assertEquals(CAP, spot.maxWidth) } @Test - fun `a chat filling the pane yields no room rather than a negative width`() { - val spot = beside(chat = Rectangle(0, 0, 2000, 1000)) + fun `a card filling the pane yields no room rather than a negative width`() { + val spot = beside(card = Rectangle(0, 0, 2000, 40)) assertEquals(0, spot.maxWidth) } @@ -73,19 +89,15 @@ class HeaderPopupGeometryTest { @Test fun `chrome is reserved so the balloon still fits its side`() { // The side has 400px; a body of the full 400 would overflow once the balloon adds its border, - // pointer and shadow, and an overflowing balloon gets re-pointed above or below the chat. - val spot = beside(chat = Rectangle(0, 0, 1600, 1000)) + // pointer and shadow, and an overflowing balloon gets re-pointed above or below the card. + val spot = beside(card = Rectangle(0, 0, 1600, 40)) assertTrue(spot.maxWidth + CHROME <= 400) } @Test - fun `a chat with no usable room on either side still resolves to a horizontal side`() { - val tight = HeaderPopupGeometry.beside( - pane = Rectangle(0, 0, 2000, 1000), - chat = Rectangle(0, 0, 1980, 1000), - fit = fit(), - ) + fun `a card with no usable room on either side still resolves to a horizontal side`() { + val tight = beside(card = Rectangle(0, 0, 1980, 40)) // Neither side can fit the chrome, but above/below must never be the answer. assertTrue(tight.position == Balloon.Position.atRight || tight.position == Balloon.Position.atLeft) @@ -93,39 +105,116 @@ class HeaderPopupGeometryTest { } @Test - fun `height is capped to the pane minus gaps`() { + fun `height is capped to the session minus gaps`() { val short = HeaderPopupGeometry.beside( pane = Rectangle(0, 0, 2000, 200), - chat = Rectangle(0, 0, 300, 200), + card = Rectangle(0, 0, 300, 40), + view = Rectangle(0, 0, 300, 200), fit = fit(), ) - // 200 pane, minus both gaps and the chrome the balloon reserves vertically. + // 200 session, minus both gaps and the chrome the balloon reserves vertically. assertEquals(200 - GAP * 2 - CHROME_HEIGHT, short.maxHeight) } @Test - fun `pointer target keeps a tall body inside the pane`() { - val pane = Rectangle(0, 0, 2000, 1000) + fun `height follows a short session inside a tall pane`() { + // Session in an editor tab or a short tool window: the window has room the session does not. + val spot = HeaderPopupGeometry.beside( + pane = Rectangle(0, 0, 2000, 1000), + card = Rectangle(0, 100, 300, 40), + view = Rectangle(0, 100, 300, 300), + fit = fit(), + ) + + assertEquals(300 - GAP * 2 - CHROME_HEIGHT, spot.maxHeight) + } + + @Test + fun `height follows the session even when the card is a collapsed header`() { + val spot = beside(card = Rectangle(0, 0, 300, 30)) - // Row near the top: target pushed down so the centred body clears the top edge. - assertEquals(310, HeaderPopupGeometry.centerY(pane, y = 20, height = 600, gap = GAP)) - // Row near the bottom: target pulled up. - assertEquals(690, HeaderPopupGeometry.centerY(pane, y = 980, height = 600, gap = GAP)) - // Row with room on both sides is left alone. - assertEquals(500, HeaderPopupGeometry.centerY(pane, y = 500, height = 600, gap = GAP)) + assertEquals(CAP_HEIGHT, spot.maxHeight) } @Test - fun `body taller than the pane is centred instead of clamped to an empty range`() { - val pane = Rectangle(0, 0, 2000, 400) + fun `pointer stays on the row when the body already fits`() { + val aim = aim( + view = Rectangle(0, 0, 300, 1000), + card = Rectangle(0, 400, 300, 40), + y = 420, + height = 300, + ) + + assertEquals(420, aim.y) + assertEquals(150, aim.distance) + } - assertEquals(200, HeaderPopupGeometry.centerY(pane, y = 10, height = 900, gap = GAP)) + @Test + fun `body shifts down while the pointer stays on the top row`() { + val view = Rectangle(0, 0, 300, 1000) + val aim = aim(view = view, card = Rectangle(0, 20, 300, 40), y = 40, height = 600) + + assertEquals(40, aim.y) + assertEquals(GAP, aim.y - aim.distance) } - private fun beside(chat: Rectangle) = HeaderPopupGeometry.beside( + @Test + fun `body shifts up while the pointer stays on the bottom row`() { + val view = Rectangle(0, 0, 300, 1000) + val aim = aim(view = view, card = Rectangle(0, 940, 300, 40), y = 960, height = 600) + + assertEquals(960, aim.y) + assertEquals(view.y + view.height - GAP, aim.y - aim.distance + 600) + } + + @Test + fun `pointer stays inside a collapsed card`() { + val card = Rectangle(0, 100, 300, 30) + val aim = aim(view = Rectangle(0, 0, 300, 1000), card = card, y = 115, height = 300) + + assertTrue(card.contains(0, aim.y)) + assertTrue(aim.distance in INDENT..300 - INDENT) + } + + @Test + fun `card outside the visible session falls back to the view centre`() { + val aim = aim( + view = Rectangle(0, 400, 300, 400), + card = Rectangle(0, 0, 300, 40), + y = 20, + height = 300, + ) + + assertEquals(600, aim.y) + assertEquals(150, aim.distance) + } + + @Test + fun `body taller than the session is centred instead of clamped to an empty range`() { + val view = Rectangle(0, 0, 300, 400) + val aim = aim(view = view, card = Rectangle(0, 0, 300, 40), y = 20, height = 900) + + assertEquals(20, aim.y) + assertEquals(-250, aim.y - aim.distance) + assertTrue(aim.distance in INDENT..900 - INDENT) + } + + @Test + fun `pointer distance stays in the platform legal window`() { + listOf( + aim(view = Rectangle(0, 0, 300, 200), card = Rectangle(0, 0, 300, 30), y = 15, height = 160) to 160, + aim(view = Rectangle(0, 0, 300, 200), card = Rectangle(0, 170, 300, 30), y = 185, height = 160) to 160, + aim(view = Rectangle(0, 0, 300, 200), card = Rectangle(0, 80, 300, 40), y = 100, height = 500) to 500, + ).forEach { pair -> + assertTrue(pair.first.distance in INDENT..pair.second - INDENT) + } + } + + private fun beside(card: Rectangle) = HeaderPopupGeometry.beside( pane = Rectangle(0, 0, 2000, 1000), - chat = chat, + card = card, + view = Rectangle(0, 0, 2000, 1000), fit = fit(), ) @@ -136,4 +225,14 @@ class HeaderPopupGeometryTest { maxWidth = CAP, maxHeight = CAP_HEIGHT, ) + + private fun aim(view: Rectangle, card: Rectangle, y: Int, height: Int) = HeaderPopupGeometry.aim( + view = view, + card = card, + y = y, + height = height, + gap = GAP, + indent = INDENT, + ) + } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartViewTest.kt index 8f1004f729e..b5b80a333cb 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartViewTest.kt @@ -13,7 +13,9 @@ import java.awt.image.BufferedImage import javax.swing.Icon import javax.swing.JComponent import javax.swing.JLabel +import javax.swing.JLayeredPane import javax.swing.JPanel +import javax.swing.JRootPane @Suppress("UnstableApiUsage") class AbstractSessionPartViewTest : BasePlatformTestCase() { @@ -193,6 +195,45 @@ class AbstractSessionPartViewTest : BasePlatformTestCase() { assertEquals(SessionUiStyle.View.Surface.headerBgColor().rgb, row.background.rgb) } + fun `test hover survives an exit that stays on the row`() { + val view = NestedView(JLabel("link")) + val row = view.component(0) as JPanel + pane(view) + + enter(row) + // Swing reports an exit for every nested crossing; one that lands back on the row is not a + // leave, so the fill must stay. + exit(row, 5, 5) + + assertEquals(SessionUiStyle.View.Surface.headerHoverBgColor().rgb, row.background.rgb) + } + + fun `test hover clears when an overlay covers the row under the pointer`() { + val view = NestedView(JLabel("link")) + val row = view.component(0) as JPanel + val pane = pane(view) + enter(row) + assertEquals(SessionUiStyle.View.Surface.headerHoverBgColor().rgb, row.background.rgb) + + // A banner painted above the transcript owns the pointer even while it sits inside the row's + // bounds, so the row must not stay lit underneath it. + 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(view: AbstractSessionPartView): JLayeredPane { + val root = JRootPane() + root.setSize(200, 40) + root.contentPane.add(view) + view.setSize(200, 40) + view.doLayout() + root.doLayout() + root.contentPane.doLayout() + return root.layeredPane + } + fun `test clicking a nested header child toggles the card`() { val child = JLabel("plain") val header = JPanel(BorderLayout()).apply { add(child, BorderLayout.WEST) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt index 2837e0e58d0..1e17cce1bca 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt @@ -19,6 +19,7 @@ import ai.kilocode.client.ui.list.ActiveListRenderer import ai.kilocode.client.ui.list.ActiveListRowHeight import ai.kilocode.client.ui.list.ActiveListSelection import ai.kilocode.client.ui.list.ActiveListView +import ai.kilocode.client.ui.list.ActiveListWeight import ai.kilocode.client.ui.list.ACTIVE_LIST_CHANGES_CELL import ai.kilocode.client.ui.list.ACTIVE_LIST_MENU_CELL import ai.kilocode.client.ui.list.ACTIVE_LIST_PR_CELL @@ -30,6 +31,7 @@ import com.intellij.openapi.actionSystem.DefaultActionGroup import com.intellij.openapi.application.ApplicationManager import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.ui.CollectionListModel +import com.intellij.ui.GroupHeaderSeparator import com.intellij.ui.ScrollingUtil import com.intellij.ui.SimpleColoredComponent import com.intellij.ui.SimpleTextAttributes @@ -44,6 +46,7 @@ import java.awt.Dimension import java.awt.Point import java.awt.event.InputEvent import java.awt.event.MouseEvent +import java.awt.image.BufferedImage import javax.swing.JLayeredPane import javax.swing.JPanel import javax.swing.ListSelectionModel @@ -217,6 +220,61 @@ class SettingsListViewTest : BasePlatformTestCase() { } } + fun `test renderer draws the row title in plain weight when configured`() { + edt { + val row = item("with", "Alpha", "Description") + val model = CollectionListModel(listOf(row)) + val list = JBList(model) + val renderer = ActiveListRenderer(model, ActiveListConfig.Equal.copy(title = ActiveListWeight.PLAIN)) + + renderer.getListCellRendererComponent(list, row, 0, true, true) + + val title = components(renderer).filterIsInstance().single() + val iter = title.iterator() + iter.next() + assertEquals(SimpleTextAttributes.STYLE_PLAIN, iter.textAttributes.style) + assertEquals("Alpha", iter.fragment) + } + } + + fun `test renderer styles section header weight from config`() { + edt { + val first = sectionItem("one", "Alpha", "Local") + val second = sectionItem("two", "Beta", "Remote") + val model = CollectionListModel(listOf(first, second)) + val list = JBList(model) + val bold = ActiveListRenderer(model, ActiveListConfig.Equal) + val plain = ActiveListRenderer(model, ActiveListConfig.Equal.copy(header = ActiveListWeight.PLAIN)) + + bold.getListCellRendererComponent(list, second, 1, false, false) + plain.getListCellRendererComponent(list, second, 1, false, false) + + assertTrue(components(bold).filterIsInstance().single().font.isBold) + assertFalse(components(plain).filterIsInstance().single().font.isBold) + } + } + + fun `test renderer reads section divider visibility from config`() { + edt { + val first = sectionItem("one", "Alpha", "Local") + val second = sectionItem("two", "Beta", "Remote") + val model = CollectionListModel(listOf(first, second)) + val list = JBList(model) + val divider = ActiveListRenderer(model, ActiveListConfig.Equal) + val none = ActiveListRenderer(model, ActiveListConfig.Equal.copy(divider = false)) + + divider.getListCellRendererComponent(list, first, 0, false, false) + assertTrue(components(divider).filterIsInstance().single().isHideLine) + divider.getListCellRendererComponent(list, second, 1, false, false) + assertFalse(components(divider).filterIsInstance().single().isHideLine) + + none.getListCellRendererComponent(list, first, 0, false, false) + assertTrue(components(none).filterIsInstance().single().isHideLine) + none.getListCellRendererComponent(list, second, 1, false, false) + assertTrue(components(none).filterIsInstance().single().isHideLine) + } + } + fun `test narrow row squeezes title but keeps tags full width`() { edt { val row = object : ActiveListItem { @@ -254,7 +312,7 @@ class SettingsListViewTest : BasePlatformTestCase() { val list = JBList(model) val renderer = ActiveListRenderer(model, ActiveListConfig.Equal) - renderer.getListCellRendererComponent(list, row, 0, true, true) + renderer.getListCellRendererComponent(list, row, 0, false, false) renderer.setSize(320, renderer.preferredSize.height) layout(renderer) @@ -264,6 +322,47 @@ class SettingsListViewTest : BasePlatformTestCase() { } } + fun `test renderer recolors a tinted leading icon to the foreground on selection`() { + edt { + val row = object : ActiveListItem { + override val key = "with" + override val title = "Alpha" + override val icon = AllIcons.Nodes.Plugin + override val tinted = true + } + val model = CollectionListModel(listOf(row)) + val list = JBList(model) + val renderer = ActiveListRenderer(model, ActiveListConfig.Equal) + + renderer.getListCellRendererComponent(list, row, 0, false, false) + val mark = components(renderer).filterIsInstance().single { it.icon === AllIcons.Nodes.Plugin } + renderer.getListCellRendererComponent(list, row, 0, true, true) + + // At rest the row keeps the icon's own theme color; a focused selection swaps in a + // foreground-tinted copy so the glyph matches the highlighted title. + assertNotSame(AllIcons.Nodes.Plugin, mark.icon) + } + } + + fun `test renderer keeps an untinted colored icon on selection`() { + edt { + val row = object : ActiveListItem { + override val key = "with" + override val title = "Alpha" + override val icon = AllIcons.Nodes.Plugin + } + val model = CollectionListModel(listOf(row)) + val list = JBList(model) + val renderer = ActiveListRenderer(model, ActiveListConfig.Equal) + + renderer.getListCellRendererComponent(list, row, 0, true, true) + + // Colored status glyphs (running, question, error) opt out and keep their own hue: the + // leading label still holds the original icon by identity after a focused selection. + assertNotNull(components(renderer).filterIsInstance().single { it.icon === AllIcons.Nodes.Plugin }) + } + } + fun `test renderer shows optional trailing text`() { edt { val with = object : ActiveListItem { @@ -1016,6 +1115,60 @@ class SettingsListViewTest : BasePlatformTestCase() { } } + fun `test pr badge hit region ignores the metrics of other rows`() { + edt { + val calls = mutableListOf() + val view = ActiveListView("Empty") { _, _ -> } + view.update( + listOf( + metricsItem( + "wide", + "Alpha", + ActiveListMetrics( + additions = 1234, + deletions = 987, + ahead = 42, + behind = 17, + pr = ActiveListBadge("#12345"), + onPr = { calls += "wide" }, + ), + ), + metricsItem("narrow", "Beta", ActiveListMetrics(pr = ActiveListBadge("#7"), onPr = { calls += "narrow" })), + ), + ) + view.list.size = Dimension(360, 160) + view.list.doLayout() + UIUtil.dispatchAllInvocationEvents() + + val wide = activeListCellBounds(view.list, 0, selected = false).getValue(ACTIVE_LIST_PR_CELL) + val narrow = activeListCellBounds(view.list, 1, selected = false).getValue(ACTIVE_LIST_PR_CELL) + // Both badges trail their row, so they share a right edge no matter how wide the changes + // beside them are. + assertEquals(wide.x + wide.width, narrow.x + narrow.width) + + // The renderer is one reused stamp: rendering the wide row, or a full paint pass over + // every row, must not move the narrow row's hit region. + activeListCellBounds(view.list, 0, selected = false) + assertEquals(narrow, activeListCellBounds(view.list, 1, selected = false).getValue(ACTIVE_LIST_PR_CELL)) + paint(view.list) + assertEquals(narrow, activeListCellBounds(view.list, 1, selected = false).getValue(ACTIVE_LIST_PR_CELL)) + + click(view, center(narrow)) + click(view, center(wide)) + assertEquals(listOf("narrow", "wide"), calls) + } + } + + private fun paint(list: JBList<*>) { + val image = UIUtil.createImage(list, list.width, list.height, BufferedImage.TYPE_INT_ARGB) + val g = image.createGraphics() + try { + list.paint(g) + } finally { + g.dispose() + } + } + private fun item(id: String, name: String, note: String?, vararg cells: ActiveListCell) = object : ActiveListItem { override val key = id override val title = name diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/LayeredOverlayPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/LayeredOverlayPanelTest.kt index ef4f95f1c02..8867dbbc0e4 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/LayeredOverlayPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/LayeredOverlayPanelTest.kt @@ -3,7 +3,10 @@ package ai.kilocode.client.ui import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.util.ui.components.BorderLayoutPanel import java.awt.Dimension +import java.awt.Point import java.awt.Rectangle +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent import javax.swing.JLayeredPane @Suppress("UnstableApiUsage") @@ -102,6 +105,72 @@ class LayeredOverlayPanelTest : BasePlatformTestCase() { assertTrue(root.blocker.contains(50, 50)) } + fun `test a blocking overlay releases the hover of the content it covers`() { + val root = LayeredOverlayPanel().apply { setSize(400, 260) } + val hovered = Hovered() + root.content.add(hovered) + root.addOverlay(Probe(), blocks = true) { _, item -> Rectangle(0, 0, item.preferredSize.width, item.preferredSize.height) } + root.doLayout() + + root.releaseHover(Point(20, 10)) + + assertEquals(1, hovered.exits) + } + + fun `test content keeps its hover where no blocking overlay covers it`() { + val root = LayeredOverlayPanel().apply { setSize(400, 260) } + val hovered = Hovered() + root.content.add(hovered) + root.addOverlay(Probe(), blocks = true) { _, item -> Rectangle(0, 0, item.preferredSize.width, item.preferredSize.height) } + root.doLayout() + + root.releaseHover(Point(200, 200)) + + assertEquals(0, hovered.exits) + } + + fun `test a decorating overlay leaves the hover of the content below alone`() { + val root = LayeredOverlayPanel().apply { setSize(400, 260) } + val hovered = Hovered() + root.content.add(hovered) + // A hover affordance drawn for the row it sits on must not take that row's hover away. + root.addOverlay(Probe()) { _, item -> Rectangle(0, 0, item.preferredSize.width, item.preferredSize.height) } + root.doLayout() + + root.releaseHover(Point(20, 10)) + + assertEquals(0, hovered.exits) + } + + fun `test the blocker releases the hover of the content under the pointer`() { + val root = LayeredOverlayPanel().apply { setSize(400, 260) } + val hovered = Hovered() + root.content.add(hovered) + root.doLayout() + + root.releaseHover(Point(20, 10)) + assertEquals(0, hovered.exits) + + root.setBlocked(true) + root.releaseHover(Point(20, 10)) + + assertEquals(1, hovered.exits) + } + + private class Hovered : BorderLayoutPanel() { + var exits = 0 + private set + + init { + setBounds(0, 0, 400, 260) + addMouseListener(object : MouseAdapter() { + override fun mouseExited(e: MouseEvent) { + exits++ + } + }) + } + } + private class Probe : BorderLayoutPanel() { var laid = false