diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenWorktreeDiffAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenWorktreeDiffAction.kt
new file mode 100644
index 00000000000..2ad08de46b7
--- /dev/null
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenWorktreeDiffAction.kt
@@ -0,0 +1,23 @@
+package ai.kilocode.client.actions
+
+import ai.kilocode.client.agentManager.SidePanelKeys
+import ai.kilocode.client.agentManager.worktree.WorktreeDataKeys
+import com.intellij.openapi.actionSystem.ActionUpdateThread
+import com.intellij.openapi.actionSystem.AnAction
+import com.intellij.openapi.actionSystem.AnActionEvent
+
+class OpenWorktreeDiffAction : AnAction() {
+ override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT
+
+ override fun update(e: AnActionEvent) {
+ val panel = e.getData(SidePanelKeys.WORKTREE_PANEL)
+ val item = e.getData(WorktreeDataKeys.WORKTREE)
+ e.presentation.isEnabledAndVisible = panel != null && panel.canOpenDiff(item)
+ }
+
+ override fun actionPerformed(e: AnActionEvent) {
+ val panel = e.getData(SidePanelKeys.WORKTREE_PANEL) ?: return
+ val item = e.getData(WorktreeDataKeys.WORKTREE) ?: return
+ if (panel.canOpenDiff(item)) panel.openDiff(item)
+ }
+}
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenWorktreePrAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenWorktreePrAction.kt
new file mode 100644
index 00000000000..adba5e18b1f
--- /dev/null
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenWorktreePrAction.kt
@@ -0,0 +1,23 @@
+package ai.kilocode.client.actions
+
+import ai.kilocode.client.agentManager.SidePanelKeys
+import ai.kilocode.client.agentManager.worktree.WorktreeDataKeys
+import com.intellij.openapi.actionSystem.ActionUpdateThread
+import com.intellij.openapi.actionSystem.AnAction
+import com.intellij.openapi.actionSystem.AnActionEvent
+
+class OpenWorktreePrAction : AnAction() {
+ override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT
+
+ override fun update(e: AnActionEvent) {
+ val panel = e.getData(SidePanelKeys.WORKTREE_PANEL)
+ val item = e.getData(WorktreeDataKeys.WORKTREE)
+ e.presentation.isEnabledAndVisible = panel != null && panel.canOpenPr(item)
+ }
+
+ override fun actionPerformed(e: AnActionEvent) {
+ val panel = e.getData(SidePanelKeys.WORKTREE_PANEL) ?: return
+ val item = e.getData(WorktreeDataKeys.WORKTREE) ?: return
+ if (panel.canOpenPr(item)) panel.openPr(item)
+ }
+}
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 7488a715eb2..e09f70992bf 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
@@ -98,7 +98,7 @@ class AgentManagerPanel(
},
onSelect = { selectedRow()?.dto?.id?.let { selected = it } },
menu = ActiveListMenu(WorktreeDataKeys.WORKTREE, group, element = { row ->
- (row as? WorktreeRow)?.dto?.takeIf { canRename(it) || canDelete(it) }
+ (row as? WorktreeRow)?.dto?.takeIf { canRename(it) || canDelete(it) || canOpenPr(it) || canOpenDiff(it) }
}),
)
private var selected: String? = null
@@ -206,6 +206,26 @@ class AgentManagerPanel(
internal fun canDelete(item: WorktreeDto?): Boolean = deletable(item)
+ internal fun canOpenPr(item: WorktreeDto?): Boolean = prUrl(item) != null
+
+ internal fun openPr(item: WorktreeDto) = prUrl(item)?.let { BrowserUtil.browse(it) }
+
+ /** The PR URL for [item], or null when it has none or is not in a stable, openable state. */
+ private fun prUrl(item: WorktreeDto?): String? {
+ if (item == null || item.main) return null
+ if (controller.isPending(item.id) || controller.isDeleting(item.id)) return null
+ return prs[normalizeWorktreePath(item.path)]?.url
+ }
+
+ internal fun canOpenDiff(item: WorktreeDto?): Boolean {
+ if (item == null || item.main || project == null) return false
+ return !controller.isPending(item.id) && !controller.isDeleting(item.id)
+ }
+
+ internal fun openDiff(item: WorktreeDto) {
+ if (canOpenDiff(item)) openBranchDiff(item.path)
+ }
+
private fun showDeletePopup(item: WorktreeDto, cell: String? = null) {
val opts = ActiveListDeleteOptions(
message = KiloBundle.message("worktree.delete.confirm.message", item.name),
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 459485d1317..46ac169c843 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
@@ -8,10 +8,7 @@ import ai.kilocode.client.ui.list.ACTIVE_LIST_CHANGES_CELL
import ai.kilocode.client.ui.list.ACTIVE_LIST_PR_CELL
import ai.kilocode.client.ui.list.ActiveListBadge
import ai.kilocode.client.ui.list.ActiveListHitCell
-import ai.kilocode.client.ui.layout.HAlign
import ai.kilocode.client.ui.layout.Stack
-import ai.kilocode.client.ui.layout.VAlign
-import ai.kilocode.client.ui.layout.align
import ai.kilocode.rpc.dto.GhState
import ai.kilocode.rpc.dto.WorktreePrDto
import ai.kilocode.rpc.dto.WorktreeStatsDto
@@ -47,10 +44,9 @@ internal class WorktreeStatsView(
// ids back and routes the click. Standalone (toolbar) usage keeps its own listeners below.
private val changeHit = HitRegion(ACTIVE_LIST_CHANGES_CELL).apply { add(change, BorderLayout.CENTER) }
private val prHit = HitRegion(ACTIVE_LIST_PR_CELL).apply { add(pr, BorderLayout.CENTER) }
- // Change badge and PR link stack vertically, each pinned to the trailing edge.
- private val changeLine = changeHit.align(HAlign.RIGHT, VAlign.CENTER)
- private val prLine = prHit.align(HAlign.RIGHT, VAlign.CENTER)
- private val row = Stack.vertical(UiStyle.Gap.sm()).next(changeLine).next(prLine)
+ // Change badge and PR link share one row: the changes trail first, the PR link is pinned last so
+ // 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
@@ -59,7 +55,7 @@ internal class WorktreeStatsView(
add(row)
changeHit.act = openDiff
prHit.act = { url?.let(BrowserUtil::browse) }
- diff.toolTipText = KiloBundle.message("worktree.stats.diff.tooltip", 0, 0)
+ diff.toolTipText = KiloBundle.message("worktree.stats.tooltip", 0, 0, 0, 0)
changeHit.addMouseListener(object : MouseAdapter() {
override fun mouseClicked(event: MouseEvent) {
changeHit.act?.invoke()
@@ -107,18 +103,20 @@ internal class WorktreeStatsView(
ahead.isVisible = s.ahead > 0
diff.update(s.additions, s.deletions)
diff.isVisible = s.additions > 0 || s.deletions > 0
- val diffTip = KiloBundle.message("worktree.stats.diff.tooltip", s.additions, s.deletions)
- diff.toolTipText = diffTip
- changeHit.tip = diffTip
+ // One descriptive tooltip for the whole changes region: ahead/behind, the +/- diff, and what
+ // clicking does.
+ val changeTip = KiloBundle.message("worktree.stats.tooltip", s.ahead, s.behind, s.additions, s.deletions)
+ 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
val changesVisible = behind.isVisible || ahead.isVisible || diff.isVisible
- changeLine.isVisible = changesVisible
changeHit.isVisible = changesVisible
- prLine.isVisible = pr.isVisible
prHit.isVisible = pr.isVisible
isVisible = changesVisible || pr.isVisible
applyCursors()
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 15a19e92913..6b9a14eca8c 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
@@ -85,14 +85,20 @@ internal class ActiveListRenderer(
// instead of drifting to the far right of the row.
private val header = titleGroup.align(HAlign.LEFT, VAlign.CENTER)
private val desc = JBLabel()
- private val text = Stack.vertical().next(header).next(desc)
+ private val metrics = WorktreeStatsView(fill = false)
+ private val metricsPane = metrics.align(HAlign.RIGHT, VAlign.CENTER)
+ // The description (branch) line carries the changes/PR metrics on its trailing edge so they sit
+ // on the branch row instead of spanning the full row height.
+ private val descLine = JPanel(BorderLayout(UiStyle.Gap.md(), 0)).apply {
+ add(desc, BorderLayout.CENTER)
+ add(metricsPane, BorderLayout.EAST)
+ }
+ private val text = Stack.vertical().next(header).next(descLine)
private val textPane = text.align(HAlign.TRACK, VAlign.CENTER)
private val trail = JBLabel().apply { horizontalAlignment = SwingConstants.RIGHT }
- private val metrics = WorktreeStatsView(fill = false)
private val trailPane = trail.align(HAlign.RIGHT, VAlign.CENTER)
private val endPane = JPanel(BorderLayout()).apply {
add(trailPane, BorderLayout.CENTER)
- add(metrics, BorderLayout.EAST)
}
private val cells = Stack.horizontal(activeListCellGap())
private val cellPane = cells.align(HAlign.RIGHT, VAlign.CENTER)
@@ -135,8 +141,10 @@ internal class ActiveListRenderer(
text,
textPane,
desc,
- trail,
+ descLine,
metrics,
+ metricsPane,
+ trail,
trailPane,
endPane,
cells,
@@ -214,6 +222,11 @@ internal class ActiveListRenderer(
trail.text = end
trail.isVisible = end.isNotBlank() && data == null
metrics.isVisible = data != null && !value.deleting
+ // Hide the wrapper too so a metrics-less row does not reserve the trailing gap on its
+ // description, and collapse the whole second row when it would be empty so title-only rows
+ // stay vertically centered.
+ metricsPane.isVisible = metrics.isVisible
+ descLine.isVisible = desc.isVisible || metrics.isVisible
trail.foreground = weak
val hovered = (list as? ActiveListActive)?.hoveredIndex() == index
diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml
index 78d6b1a8ea9..414ab991e0e 100644
--- a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml
+++ b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml
@@ -241,6 +241,12 @@
+
+
+
+
@@ -250,6 +256,9 @@
diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties
index 84710269275..5312aebca82 100644
--- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties
+++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties
@@ -369,7 +369,8 @@ worktree.configure.branch.required=Branch name is required
worktree.stats.diff.tooltip={0} additions, {1} deletions
worktree.stats.ahead.tooltip=Commits ahead of base branch
worktree.stats.behind.tooltip=Commits behind base branch
-worktree.pr.tooltip=Pull request #{0} ({1})
+worktree.stats.tooltip={0} commits ahead, {1} behind, +{2} -{3}. Click to open the diff against the base branch.
+worktree.pr.tooltip=Pull request #{0} ({1}). Click to open it in your browser.
worktree.gh.missing.title=GitHub CLI not found
worktree.gh.missing.content=Install gh to show pull request badges for worktrees.
worktree.gh.install=Install
@@ -794,6 +795,10 @@ action.Kilo.Worktree.Rename.text=Rename
action.Kilo.Worktree.Rename.description=Rename the selected worktree
action.Kilo.Worktree.Delete.text=Delete
action.Kilo.Worktree.Delete.description=Delete the selected worktree
+action.Kilo.Worktree.OpenPr.text=Open Pull Request
+action.Kilo.Worktree.OpenPr.description=Open the pull request in your browser
+action.Kilo.Worktree.OpenDiff.text=Open Diff
+action.Kilo.Worktree.OpenDiff.description=Open the diff against the base branch
action.Kilo.WorktreeSession.Rename.text=Rename
action.Kilo.WorktreeSession.Rename.description=Rename the selected worktree session
action.Kilo.WorktreeSession.Delete.text=Delete
diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/HistorySessionActionsTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/HistorySessionActionsTest.kt
index 4694ac6cf71..d5ee54489aa 100644
--- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/HistorySessionActionsTest.kt
+++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/HistorySessionActionsTest.kt
@@ -341,6 +341,8 @@ class HistorySessionActionsTest : BasePlatformTestCase() {
assertTrue(xml.contains("id=\"Kilo.Session.Delete\""))
assertTrue(xml.contains("id=\"Kilo.Worktree.Rename\""))
assertTrue(xml.contains("id=\"Kilo.Worktree.Delete\""))
+ assertTrue(xml.contains("id=\"Kilo.Worktree.OpenPr\""))
+ assertTrue(xml.contains("id=\"Kilo.Worktree.OpenDiff\""))
assertTrue(xml.contains("id=\"Kilo.WorktreeSession.Rename\""))
assertTrue(xml.contains("id=\"Kilo.WorktreeSession.Delete\""))
assertTrue(xml.contains("id=\"Kilo.Worktree.RowMenu\""))
@@ -352,9 +354,18 @@ class HistorySessionActionsTest : BasePlatformTestCase() {
assertTrue(xml.contains("ref=\"Kilo.Session.Delete\""))
assertTrue(xml.contains("ref=\"Kilo.Worktree.Rename\""))
assertTrue(xml.contains("ref=\"Kilo.Worktree.Delete\""))
+ assertTrue(xml.contains("ref=\"Kilo.Worktree.OpenPr\""))
+ assertTrue(xml.contains("ref=\"Kilo.Worktree.OpenDiff\""))
assertTrue(xml.contains("ref=\"Kilo.WorktreeSession.Rename\""))
assertTrue(xml.contains("ref=\"Kilo.WorktreeSession.Delete\""))
assertTrue(xml.contains("ref=\"${'$'}Copy\""))
+
+ // Row menu order: rename, (separator), open pr, open diff, (separator), delete.
+ val rename = xml.indexOf("ref=\"Kilo.Worktree.Rename\"")
+ val openPr = xml.indexOf("ref=\"Kilo.Worktree.OpenPr\"")
+ val openDiff = xml.indexOf("ref=\"Kilo.Worktree.OpenDiff\"")
+ val delete = xml.indexOf("ref=\"Kilo.Worktree.Delete\"")
+ assertTrue(rename in 0 until openPr && openPr < openDiff && openDiff < delete)
}
// ------ Helpers ------
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 82864377f78..415c7f02131 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
@@ -9,6 +9,7 @@ import ai.kilocode.client.agentManager.worktree.WorktreeSessionEditorKind
import ai.kilocode.client.agentManager.worktree.WorktreeStatusService
import ai.kilocode.client.agentManager.worktree.ensureWorktreeSessionEditorKind
import ai.kilocode.client.agentManager.worktree.worktreeSessionParams
+import ai.kilocode.client.diff.KiloDiffEditorKind
import ai.kilocode.client.testing.FakeWorktreeRpcApi
import ai.kilocode.client.testing.TestCoroutines
import ai.kilocode.client.testing.TestUiTimers
@@ -22,7 +23,11 @@ import ai.kilocode.client.vfs.KiloPath
import ai.kilocode.client.vfs.KiloVfsManager
import ai.kilocode.client.vfs.KiloVirtualFile
import ai.kilocode.client.vfs.KiloVirtualFileSystem
+import ai.kilocode.rpc.dto.GhAvailability
+import ai.kilocode.rpc.dto.GhState
import ai.kilocode.rpc.dto.WorktreeDto
+import ai.kilocode.rpc.dto.WorktreePrDto
+import ai.kilocode.rpc.dto.WorktreePrListDto
import ai.kilocode.rpc.dto.WorktreeStatsDto
import ai.kilocode.rpc.dto.WorktreeStatsListDto
import ai.kilocode.rpc.dto.SessionActivityDto
@@ -416,6 +421,39 @@ class AgentManagerPanelTest : BasePlatformTestCase() {
assertEquals(3, metrics.behind)
}
+ fun `test open diff opens the branch diff editor`() {
+ val item = WorktreeDto("${project.basePath!!}/.kilo/worktrees/feature-x", "feature-x", "feature/x", "${project.basePath!!}/.kilo/worktrees/feature-x")
+ rpc.listed += item
+ val controller = WorktreeController(service, project.basePath!!, coroutines.scope)
+ val panel = edt { AgentManagerPanel(testRootDisposable, controller, project) }
+ edt { controller.reload() }
+ flush()
+
+ assertTrue(edt { panel.canOpenDiff(item) })
+ edt { panel.openDiff(item) }
+
+ val file = edt { FileEditorManager.getInstance(project).openFiles.single() as KiloVirtualFile }
+ assertEquals(KiloDiffEditorKind.ID, file.path.kind)
+ assertEquals(item.path, file.path.params["directory"])
+ }
+
+ fun `test open pr availability reflects pr status`() {
+ val item = WorktreeDto("${project.basePath!!}/.kilo/worktrees/feature-x", "feature-x", "feature/x", "${project.basePath!!}/.kilo/worktrees/feature-x")
+ rpc.listed += item
+ rpc.prResult = WorktreePrListDto(GhAvailability.OK, listOf(WorktreePrDto(item.path, 7, GhState.OPEN, "https://example.test/pr/7")))
+ val timers = TestUiTimers()
+ ApplicationManager.getApplication().replaceService(KiloWorktreeService::class.java, service, testRootDisposable)
+ project.replaceService(WorktreeStatusService::class.java, WorktreeStatusService(project, coroutines.scope, timers), testRootDisposable)
+ val controller = WorktreeController(service, project.basePath!!, coroutines.scope)
+ val panel = edt { AgentManagerPanel(testRootDisposable, controller, project) }
+ edt { controller.reload() }
+ timers.advanceBy(300)
+ flush()
+
+ assertTrue(edt { panel.canOpenPr(item) })
+ assertFalse(edt { panel.canOpenPr(null) })
+ }
+
fun `test worktree row hides badge while pending or deleting`() {
val path = "feature/y"
val activity = MutableStateFlow(mapOf(
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 24394be4ac1..c83ecee8d43 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
@@ -919,7 +919,7 @@ class SettingsListViewTest : BasePlatformTestCase() {
UIUtil.dispatchAllInvocationEvents()
val area = activeListCellBounds(view.list, 0, selected = true).getValue(ACTIVE_LIST_CHANGES_CELL)
- assertEquals(KiloBundle.message("worktree.stats.diff.tooltip", 3, 2), view.list.getToolTipText(event(view.list, center(area))))
+ assertEquals(KiloBundle.message("worktree.stats.tooltip", 0, 0, 3, 2), view.list.getToolTipText(event(view.list, center(area))))
hover(view, center(area))
assertEquals(Cursor.HAND_CURSOR, view.list.cursor.type)