Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/jetbrains-empty-session-worktree-tip.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": minor
---

Show a branch-aware tip on the empty session screen: suggest running work in a worktree with a link when you're on a plain checkout, and confirm isolation when you're already in one.
5 changes: 5 additions & 0 deletions .changeset/jetbrains-toolwindow-create-buttons.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---

Label the Kilo tool window's create buttons as + Session and + Worktree with a compact plus icon.
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import ai.kilocode.log.KiloLog
import com.intellij.openapi.actionSystem.ActionGroup
import com.intellij.openapi.actionSystem.ActionManager
import com.intellij.openapi.actionSystem.DataProvider
import com.intellij.openapi.actionSystem.Separator
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import com.intellij.openapi.project.DumbAware
Expand Down Expand Up @@ -178,6 +179,7 @@ internal class KiloToolWindowSetupService(
val actions = listOfNotNull(
ActionManager.getInstance().getAction("Kilo.NewSession"),
ActionManager.getInstance().getAction("Kilo.NewWorktree"),
Separator.create(),
ActionManager.getInstance().getAction("Kilo.History"),
)
toolWindow.setTitleActions(actions)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package ai.kilocode.client.actions

import com.intellij.openapi.util.IconLoader
import javax.swing.Icon

internal object KiloActionIcons {
val add: Icon = IconLoader.getIcon("/icons/add-small.svg", KiloActionIcons::class.java)
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@ import ai.kilocode.client.session.SessionManager
import ai.kilocode.client.telemetry.Telemetry
import ai.kilocode.client.agentManager.SidePanelKeys
import ai.kilocode.client.agentManager.SidePanelMode
import com.intellij.icons.AllIcons
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.actionSystem.ex.ActionUtil
import com.intellij.openapi.project.DumbAware

class NewSessionAction : AnAction(
KiloBundle.message("action.Kilo.NewSession.text"),
KiloBundle.message("action.Kilo.NewSession.description"),
AllIcons.General.Add,
KiloActionIcons.add,
), DumbAware {
override fun actionPerformed(e: AnActionEvent) {
Telemetry.send("New Session Clicked", mapOf("surface" to "tool_window"))
Expand All @@ -23,5 +23,9 @@ class NewSessionAction : AnAction(
override fun update(e: AnActionEvent) {
e.presentation.isVisible = e.getData(SidePanelKeys.MODE) != SidePanelMode.AGENT_MANAGER
e.presentation.isEnabled = e.getData(SessionManager.KEY) != null
e.presentation.icon = KiloActionIcons.add
if (!e.isFromActionToolbar) return
e.presentation.text = KiloBundle.message("action.Kilo.NewSession.toolbar")
e.presentation.putClientProperty(ActionUtil.SHOW_TEXT_IN_TOOLBAR, true)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,31 @@ package ai.kilocode.client.actions

import ai.kilocode.client.agentManager.SidePanelKeys
import ai.kilocode.client.agentManager.SidePanelMode
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.telemetry.Telemetry
import com.intellij.icons.AllIcons
import com.intellij.openapi.actionSystem.ActionUpdateThread
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.actionSystem.ex.ActionUtil
import com.intellij.openapi.project.DumbAware

/**
* `+` toolbar action shown in Agent Manager mode. Opens the New Worktree dialog (New + Import tabs).
*/
class NewWorktreeAction : AnAction(), DumbAware {
class NewWorktreeAction : AnAction(
KiloBundle.message("action.Kilo.NewWorktree.text"),
KiloBundle.message("action.Kilo.NewWorktree.description"),
KiloActionIcons.add,
), DumbAware {
override fun getActionUpdateThread() = ActionUpdateThread.BGT

override fun update(e: AnActionEvent) {
e.presentation.isVisible = e.getData(SidePanelKeys.MODE) == SidePanelMode.AGENT_MANAGER
e.presentation.isEnabled = e.getData(SidePanelKeys.WORKTREE_PANEL) != null
e.presentation.icon = AllIcons.General.Add
e.presentation.icon = KiloActionIcons.add
if (!e.isFromActionToolbar) return
e.presentation.text = KiloBundle.message("action.Kilo.NewWorktree.toolbar")
e.presentation.putClientProperty(ActionUtil.SHOW_TEXT_IN_TOOLBAR, true)
}

override fun actionPerformed(e: AnActionEvent) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ abstract class SessionHost(
activity = { activity() },
titles = { titles() },
timers = timers,
newWorktree = if (supportsNewWorktree) ({ newWorktree() }) else null,
)

@RequiresEdt
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ interface SessionManager {
history = { showHistory() },
activity = { activity() },
titles = { titles() },
newWorktree = if (supportsNewWorktree) ({ newWorktree() }) else null,
)

fun openSession(session: SessionDto) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ import ai.kilocode.client.util.UiTimerSource
import ai.kilocode.client.util.UiTimers
import ai.kilocode.client.vfs.KiloVfsManager
import ai.kilocode.log.ChatLogSummary
import ai.kilocode.rpc.dto.BranchStatusDto
import ai.kilocode.rpc.dto.ModelLimitDto
import ai.kilocode.rpc.dto.DiffFileDto
import ai.kilocode.rpc.dto.PromptDto
Expand Down Expand Up @@ -213,6 +214,12 @@ class SessionUi(
private lateinit var load: LoadingPanel
private lateinit var migrationWizard: MigrationWizardPanel
private var empty: EmptySessionPanel? = null

/**
* Last observed branch/worktree status. Retained so an empty panel created after the fetch shows
* its tip immediately instead of waiting for the next refresh.
*/
private var branch: BranchStatusDto? = null
private var modalFocus: (() -> JComponent)? = null
private var style = SessionEditorStyle.current()
private val selection = SessionSelection()
Expand Down Expand Up @@ -247,8 +254,8 @@ class SessionUi(
dock?.let {
syncDock()
refreshBranchChanges()
refreshBranch()
}
refreshBranch()
loaded?.let(::finishOpen)
}

Expand Down Expand Up @@ -613,6 +620,7 @@ class SessionUi(
val panel = manager?.emptyPanel(this, controller)
?: EmptySessionPanel(this, controller, controller.recents(), timers = timers)
empty = panel
panel.setBranch(branch)
scroll.show(panel.view)
}

Expand Down Expand Up @@ -1023,7 +1031,9 @@ class SessionUi(
* split mode — so the PR always matches the branch checked out in this session's directory.
*/
private fun refreshBranch() {
val dock = dock ?: return
// Also feeds the empty panel's branch/worktree tip, so this runs even on surfaces without a
// dock (worktree editor tabs). Read-only surfaces show no tip and get no fetch.
if (readonly) return
branchJob?.cancel()
branchJob = cs.launch {
val status = runCatching { service<KiloWorktreeService>().branchStatus(workspace.directory) }
Expand All @@ -1034,7 +1044,9 @@ class SessionUi(
}
withContext(Dispatchers.Main) {
if (disposed || project.isDisposed) return@withContext
dock.setBranch(status)
branch = status
dock?.setBranch(status)
empty?.setBranch(status)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,16 @@ import ai.kilocode.client.ui.layout.VAlign
import ai.kilocode.client.ui.layout.align
import ai.kilocode.client.util.UiTimerSource
import ai.kilocode.client.util.UiTimers
import ai.kilocode.rpc.dto.BranchStatusDto
import ai.kilocode.rpc.dto.GhAvailability
import ai.kilocode.rpc.dto.SessionDto
import com.intellij.icons.AllIcons
import com.intellij.ide.BrowserUtil
import com.intellij.openapi.Disposable
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.util.IconLoader
import com.intellij.openapi.util.text.HtmlChunk
import com.intellij.openapi.util.text.StringUtil
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.Centerizer
Expand All @@ -39,6 +43,8 @@ import java.awt.event.MouseEvent
import javax.swing.JButton
import javax.swing.JComponent
import javax.swing.SwingUtilities
import javax.swing.event.HyperlinkEvent
import javax.swing.event.HyperlinkListener

/**
* Empty-session panel.
Expand All @@ -56,6 +62,7 @@ class EmptySessionPanel(
private val browse: (String) -> Unit = BrowserUtil::browse,
private val timers: UiTimerSource = UiTimers,
private val minimal: Boolean = false,
private val newWorktree: (() -> Unit)? = null,
) : BorderLayoutPanel(), Disposable, SessionEditorStyleTarget {
private var style = SessionEditorStyle.current()
val view: Align = align(
Expand All @@ -71,6 +78,12 @@ class EmptySessionPanel(
addActionListener { history() }
}

/**
* Branch/worktree status behind the tip under the logo. Null until [setBranch] delivers it, so
* the first paint falls back to the generic welcome rather than flashing a wrong claim.
*/
private var branch: BranchStatusDto? = null

private val feedback = EmptySessionFeedback(browse)

private val logo = JBLabel(
Expand All @@ -79,10 +92,21 @@ class EmptySessionPanel(
horizontalAlignment = JBLabel.CENTER
}

private val welcomeLabel = JBLabel(welcomeHtml()).apply {
/**
* Text is set by [syncTip], which picks the generic welcome or a branch/worktree tip. Copyable
* mode swaps the label's internals for an HTML pane, which is what makes the inline worktree
* link clickable; auto-wrapping must be set first so that pane's CSS allows line breaks.
*/
private val welcomeLabel = object : JBLabel() {
override fun createHyperlinkListener() = HyperlinkListener { e ->
if (e.eventType != HyperlinkEvent.EventType.ACTIVATED) return@HyperlinkListener
if (e.description == WORKTREE_HREF) newWorktree?.invoke()
}
}.apply {
foreground = SessionUiStyle.Text.Secondary.foreground()
horizontalAlignment = JBLabel.CENTER
setAllowAutoWrapping(true)
setCopyable(true)
}

private val description = object : BorderLayoutPanel() {
Expand All @@ -101,6 +125,13 @@ class EmptySessionPanel(
add(welcomeLabel, BorderLayout.CENTER)
}

private val descriptionSlot = description.align(HAlign.CENTER, VAlign.CENTER)

private val header = BorderLayoutPanel(0, UiStyle.Gap.pad()).apply {
isOpaque = false
add(logo, BorderLayout.NORTH)
}

init {
Disposer.register(parent, this)
Disposer.register(this, feedback)
Expand All @@ -119,12 +150,6 @@ class EmptySessionPanel(
val gap = UiStyle.Gap.pad()
layout = BorderLayout(0, gap)

val header = BorderLayoutPanel(0, gap).apply {
isOpaque = false
add(logo, BorderLayout.NORTH)
if (!minimal) add(description.align(HAlign.CENTER, VAlign.CENTER), BorderLayout.CENTER)
}

val actions = Stack.vertical(gap = UiStyle.Gap.lg())
if (!minimal) actions.next(Centerizer(historyButton, Centerizer.TYPE.HORIZONTAL))
actions.next(Centerizer(feedback.button, Centerizer.TYPE.HORIZONTAL))
Expand All @@ -136,6 +161,68 @@ class EmptySessionPanel(
add(header, BorderLayout.NORTH)
if (!minimal && recent.hasSessions()) add(recent, BorderLayout.CENTER)
add(south, BorderLayout.SOUTH)
syncTip()
}

/**
* Applies the branch/worktree status behind the tip under the logo. Called again whenever the
* status is refreshed, so it must stay a no-op when nothing changed.
*/
@RequiresEdt
fun setBranch(status: BranchStatusDto?) {
if (branch == status) return
branch = status
syncTip()
}

/**
* The tip under the logo, as an HTML fragment: an isolation reminder on a worktree, a nudge
* towards one on a plain checkout, where "run it in a worktree" is an inline link. Null when the
* status is unknown, git is missing, or no branch is checked out — the generic welcome covers
* those rather than asserting something wrong.
*/
private fun tip(): String? {
val status = branch ?: return null
if (status.availability == GhAvailability.GIT_MISSING) return null
val name = name()?.let { XmlStringUtil.escapeString(it) }
if (status.worktree) {
return name?.let { KiloBundle.message("session.empty.worktree", it) }
?: KiloBundle.message("session.empty.worktree.unknown")
}
if (name == null) return null
return KiloBundle.message("session.empty.branch", name, worktreePhrase())
}

/**
* "run it in a worktree" as a link, or as plain text on surfaces that cannot open the flow, so
* the sentence reads the same either way.
*/
private fun worktreePhrase(): String {
val phrase = KiloBundle.message("session.empty.branch.link")
if (newWorktree == null) return XmlStringUtil.escapeString(phrase)
return HtmlChunk.link(WORKTREE_HREF, phrase).toString()
}

/** Branch name trimmed to fit the fixed-width description, or null when there is no branch. */
private fun name(): String? {
val value = branch?.branch?.trim().orEmpty()
if (value.isEmpty() || value == DETACHED) return null
return StringUtil.shortenTextWithEllipsis(value, BRANCH_MAX, 0, true)
}

@RequiresEdt
private fun syncTip() {
val tip = tip()
welcomeLabel.text = centeredHtml(
tip ?: XmlStringUtil.escapeString(KiloBundle.message("session.empty.welcome")),
)
// Minimal surfaces (worktree/subagent editor tabs) skip the generic blurb but still want a
// state-specific tip, so the slot is attached on demand instead of once at construction.
val described = tip != null || !minimal
if (described && descriptionSlot.parent == null) header.add(descriptionSlot, BorderLayout.CENTER)
if (!described && descriptionSlot.parent != null) header.remove(descriptionSlot)
revalidate()
repaint()
}

internal fun recentCount() = recent.count()
Expand Down Expand Up @@ -184,6 +271,16 @@ class EmptySessionPanel(

internal fun explanationText() = KiloBundle.message("session.empty.welcome")

/** The tip as the user reads it, with the inline link's markup stripped. */
internal fun tipText() = tip()?.let { StringUtil.removeHtmlTags(it) }

/** The plain text currently under the logo: the state-specific tip, or the generic welcome. */
internal fun descriptionText() = tipText() ?: KiloBundle.message("session.empty.welcome")

internal fun worktreeLinked() = tip()?.contains("href=\"$WORKTREE_HREF\"") == true

internal fun worktreeHref() = WORKTREE_HREF

internal fun welcomeLabelAlignment() = welcomeLabel.horizontalAlignment

internal fun descriptionPreferredSize() = description.preferredSize
Expand Down Expand Up @@ -272,11 +369,19 @@ class EmptySessionPanel(
repaint()
}

private fun welcomeHtml() = XmlStringUtil.wrapInHtml(
"<div style='text-align:center'>${XmlStringUtil.escapeString(KiloBundle.message("session.empty.welcome"))}</div>"
/** [body] must already be escaped or generated HTML — this only wraps and centers it. */
private fun centeredHtml(body: String) = XmlStringUtil.wrapInHtml(
"<div style='text-align:center'>$body</div>"
)

private companion object {
const val ACTIVITY_MS = 3_000

/** Keeps a long branch name from wrapping the fixed-width description into a wall of text. */
const val BRANCH_MAX = 28
const val DETACHED = "(detached)"

/** Href of the inline worktree link; matched in the label's hyperlink listener. */
const val WORKTREE_HREF = "worktree"
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading