From d96b1b3c3d2a5424b0d5a5ecb299731cd1e88a37 Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 27 Aug 2026 12:07:49 -0400 Subject: [PATCH 1/4] fix(jetbrains): label tool window create actions --- .../jetbrains-toolwindow-create-buttons.md | 5 ++ .../kilocode/client/KiloToolWindowFactory.kt | 2 + .../client/actions/KiloActionIcons.kt | 8 ++ .../client/actions/NewSessionAction.kt | 8 +- .../client/actions/NewWorktreeAction.kt | 14 +++- .../src/main/resources/icons/add-small.svg | 3 + .../main/resources/icons/add-small_dark.svg | 3 + .../resources/messages/KiloBundle.properties | 2 + .../client/actions/NewSessionActionTest.kt | 58 ++++++++++++-- .../client/actions/NewWorktreeActionTest.kt | 75 +++++++++++++++++++ 10 files changed, 167 insertions(+), 11 deletions(-) create mode 100644 .changeset/jetbrains-toolwindow-create-buttons.md create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/KiloActionIcons.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/add-small.svg create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/add-small_dark.svg create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/NewWorktreeActionTest.kt diff --git a/.changeset/jetbrains-toolwindow-create-buttons.md b/.changeset/jetbrains-toolwindow-create-buttons.md new file mode 100644 index 0000000000..e38cb53ef1 --- /dev/null +++ b/.changeset/jetbrains-toolwindow-create-buttons.md @@ -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. 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 5718081914..45ef35c80e 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 @@ -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 @@ -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) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/KiloActionIcons.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/KiloActionIcons.kt new file mode 100644 index 0000000000..768e1de5d6 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/KiloActionIcons.kt @@ -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) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/NewSessionAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/NewSessionAction.kt index e9eec19c39..11f83d7ed1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/NewSessionAction.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/NewSessionAction.kt @@ -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")) @@ -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) } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/NewWorktreeAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/NewWorktreeAction.kt index 90c2a7d218..53a8f20f09 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/NewWorktreeAction.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/NewWorktreeAction.kt @@ -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) { diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/add-small.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/add-small.svg new file mode 100644 index 0000000000..498115b2c0 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/add-small.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/add-small_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/add-small_dark.svg new file mode 100644 index 0000000000..340edfe239 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/add-small_dark.svg @@ -0,0 +1,3 @@ + + + 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 f4f8db6c1e..93c9e4f31f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -458,8 +458,10 @@ action.Kilo.Settings.text=Settings action.Kilo.Settings.description=Kilo Code settings action.Kilo.NewSession.text=New Session action.Kilo.NewSession.description=Start a new Kilo session +action.Kilo.NewSession.toolbar=Session action.Kilo.NewWorktree.text=New Worktree action.Kilo.NewWorktree.description=Create a new git worktree +action.Kilo.NewWorktree.toolbar=Worktree action.Kilo.History.text=History action.Kilo.History.description=Show session history action.Kilo.ShowProfile.text=Profile diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/NewSessionActionTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/NewSessionActionTest.kt index 16264fd7a4..9e480f9991 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/NewSessionActionTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/NewSessionActionTest.kt @@ -1,7 +1,11 @@ package ai.kilocode.client.actions +import ai.kilocode.client.agentManager.SidePanelKeys +import ai.kilocode.client.agentManager.SidePanelMode import ai.kilocode.client.session.SessionManager import ai.kilocode.client.session.SessionRef +import com.intellij.openapi.actionSystem.ActionPlaces +import com.intellij.openapi.actionSystem.ActionUiKind import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.DataContext import com.intellij.openapi.actionSystem.Presentation @@ -29,19 +33,61 @@ class NewSessionActionTest : BasePlatformTestCase() { val action = NewSessionAction() val presentation = Presentation().apply { copyFrom(action.templatePresentation) } - ActionUtil.updateAction(action, AnActionEvent.createFromDataContext("", presentation) { null }) + ActionUtil.updateAction(action, event(action, presentation = presentation)) assertFalse(presentation.isEnabled) } + fun `test toolbar presentation uses short text`() { + val manager = FakeManager() + val action = NewSessionAction() + val event = event(action, manager = manager, ui = ActionUiKind.TOOLBAR) + + ActionUtil.updateAction(action, event) + + assertEquals("Session", event.presentation.text) + assertEquals(true, event.presentation.getClientProperty(ActionUtil.SHOW_TEXT_IN_TOOLBAR)) + assertSame(KiloActionIcons.add, event.presentation.icon) + } + + fun `test non-toolbar presentation keeps full text`() { + val manager = FakeManager() + val action = NewSessionAction() + val event = event(action, manager = manager) + + ActionUtil.updateAction(action, event) + + assertEquals("New Session", event.presentation.text) + assertNull(event.presentation.getClientProperty(ActionUtil.SHOW_TEXT_IN_TOOLBAR)) + } + + fun `test action hidden on agent manager tab`() { + val manager = FakeManager() + val action = NewSessionAction() + val event = event(action, manager = manager, mode = SidePanelMode.AGENT_MANAGER) + + ActionUtil.updateAction(action, event) + + assertFalse(event.presentation.isVisible) + } + private fun event(manager: SessionManager): AnActionEvent { - val presentation = Presentation().apply { - copyFrom(NewSessionAction().templatePresentation) - } + return event(NewSessionAction(), manager = manager) + } + + private fun event( + action: NewSessionAction, + manager: SessionManager? = null, + mode: SidePanelMode? = null, + ui: ActionUiKind = ActionUiKind.NONE, + presentation: Presentation = Presentation().apply { copyFrom(action.templatePresentation) }, + ): AnActionEvent { val context = DataContext { id -> - if (SessionManager.KEY.`is`(id)) manager else null + if (SessionManager.KEY.`is`(id)) return@DataContext manager + if (SidePanelKeys.MODE.`is`(id)) return@DataContext mode + null } - return AnActionEvent.createFromDataContext("", presentation, context) + return AnActionEvent.createEvent(context, presentation, ActionPlaces.TOOLWINDOW_TITLE, ui, null) } private class FakeManager : SessionManager { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/NewWorktreeActionTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/NewWorktreeActionTest.kt new file mode 100644 index 0000000000..5df568340f --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/NewWorktreeActionTest.kt @@ -0,0 +1,75 @@ +package ai.kilocode.client.actions + +import ai.kilocode.client.agentManager.SidePanelKeys +import ai.kilocode.client.agentManager.SidePanelMode +import com.intellij.openapi.actionSystem.ActionPlaces +import com.intellij.openapi.actionSystem.ActionUiKind +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.actionSystem.Presentation +import com.intellij.openapi.actionSystem.ex.ActionUtil +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +@Suppress("UnstableApiUsage") +class NewWorktreeActionTest : BasePlatformTestCase() { + fun `test toolbar presentation uses short text`() { + val action = NewWorktreeAction() + val event = event(action, mode = SidePanelMode.AGENT_MANAGER, ui = ActionUiKind.TOOLBAR) + + ActionUtil.updateAction(action, event) + + assertEquals("Worktree", event.presentation.text) + assertEquals(true, event.presentation.getClientProperty(ActionUtil.SHOW_TEXT_IN_TOOLBAR)) + assertSame(KiloActionIcons.add, event.presentation.icon) + } + + fun `test non-toolbar presentation keeps full text`() { + val action = NewWorktreeAction() + val event = event(action, mode = SidePanelMode.AGENT_MANAGER) + + ActionUtil.updateAction(action, event) + + assertEquals("New Worktree", event.presentation.text) + assertNull(event.presentation.getClientProperty(ActionUtil.SHOW_TEXT_IN_TOOLBAR)) + } + + fun `test action visible on agent manager tab`() { + val action = NewWorktreeAction() + val event = event(action, mode = SidePanelMode.AGENT_MANAGER) + + ActionUtil.updateAction(action, event) + + assertTrue(event.presentation.isVisible) + } + + fun `test action hidden on chat tab`() { + val action = NewWorktreeAction() + val event = event(action, mode = SidePanelMode.CHAT) + + ActionUtil.updateAction(action, event) + + assertFalse(event.presentation.isVisible) + } + + fun `test action disabled without worktree panel`() { + val action = NewWorktreeAction() + val event = event(action, mode = SidePanelMode.AGENT_MANAGER) + + ActionUtil.updateAction(action, event) + + assertFalse(event.presentation.isEnabled) + } + + private fun event( + action: NewWorktreeAction, + mode: SidePanelMode? = null, + ui: ActionUiKind = ActionUiKind.NONE, + ): AnActionEvent { + val presentation = Presentation().apply { copyFrom(action.templatePresentation) } + val context = DataContext { id -> + if (SidePanelKeys.MODE.`is`(id)) return@DataContext mode + null + } + return AnActionEvent.createEvent(context, presentation, ActionPlaces.TOOLWINDOW_TITLE, ui, null) + } +} From a453e2d231fd2275d6a0ac84bb17e3e888e29abb Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 27 Aug 2026 13:58:15 -0400 Subject: [PATCH 2/4] feat(jetbrains): hint worktree usage on empty session Show a branch-aware tip under the logo on the empty session screen. On a plain checkout it nudges toward running the task in a worktree, with "run it in a worktree" as an inline link that opens the New Worktree flow. In a worktree it confirms the work is isolated instead. The tip falls back to the generic welcome whenever a claim would be wrong: before branch status resolves, when git is missing, and on a detached HEAD. Read-only surfaces show no tip and skip the fetch. Branch status now feeds the empty panel as well as the branch dock, so worktree editor tabs get it too even though they render no dock. Translate the new strings into all 18 locales. The tip keys carry placeholders and therefore go through MessageFormat, where a lone apostrophe silently swallows surrounding text, so KiloBundleLocaleTest formats every locale for real and checks apostrophe escaping. --- .../jetbrains-empty-session-worktree-tip.md | 5 + .../ai/kilocode/client/session/SessionHost.kt | 1 + .../kilocode/client/session/SessionManager.kt | 1 + .../ai/kilocode/client/session/SessionUi.kt | 18 ++- .../session/ui/empty/EmptySessionPanel.kt | 123 +++++++++++++-- .../resources/messages/KiloBundle.properties | 4 + .../messages/KiloBundle_ar.properties | 6 + .../messages/KiloBundle_bs.properties | 6 + .../messages/KiloBundle_da.properties | 6 + .../messages/KiloBundle_de.properties | 6 + .../messages/KiloBundle_es.properties | 6 + .../messages/KiloBundle_fr.properties | 6 + .../messages/KiloBundle_ja.properties | 6 + .../messages/KiloBundle_ko.properties | 6 + .../messages/KiloBundle_nl.properties | 6 + .../messages/KiloBundle_no.properties | 6 + .../messages/KiloBundle_pl.properties | 6 + .../messages/KiloBundle_pt_BR.properties | 6 + .../messages/KiloBundle_ru.properties | 6 + .../messages/KiloBundle_th.properties | 6 + .../messages/KiloBundle_tr.properties | 6 + .../messages/KiloBundle_uk.properties | 6 + .../messages/KiloBundle_zh_CN.properties | 6 + .../messages/KiloBundle_zh_TW.properties | 6 + .../client/plugin/KiloBundleLocaleTest.kt | 100 ++++++++++++ .../session/ui/EmptySessionPanelTest.kt | 145 +++++++++++++++++- 26 files changed, 492 insertions(+), 13 deletions(-) create mode 100644 .changeset/jetbrains-empty-session-worktree-tip.md create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/plugin/KiloBundleLocaleTest.kt diff --git a/.changeset/jetbrains-empty-session-worktree-tip.md b/.changeset/jetbrains-empty-session-worktree-tip.md new file mode 100644 index 0000000000..3ceeb723f9 --- /dev/null +++ b/.changeset/jetbrains-empty-session-worktree-tip.md @@ -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. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionHost.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionHost.kt index 46b71aed5b..aebbb18afa 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionHost.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionHost.kt @@ -101,6 +101,7 @@ abstract class SessionHost( activity = { activity() }, titles = { titles() }, timers = timers, + newWorktree = if (supportsNewWorktree) ({ newWorktree() }) else null, ) @RequiresEdt diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionManager.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionManager.kt index 619ee030d3..958b2601eb 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionManager.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionManager.kt @@ -52,6 +52,7 @@ interface SessionManager { history = { showHistory() }, activity = { activity() }, titles = { titles() }, + newWorktree = if (supportsNewWorktree) ({ newWorktree() }) else null, ) fun openSession(session: SessionDto) { 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 b340f6c8be..37e999f9a7 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 @@ -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 @@ -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() @@ -247,8 +254,8 @@ class SessionUi( dock?.let { syncDock() refreshBranchChanges() - refreshBranch() } + refreshBranch() loaded?.let(::finishOpen) } @@ -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) } @@ -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().branchStatus(workspace.directory) } @@ -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) } } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionPanel.kt index c0e717938d..04fec65c3b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionPanel.kt @@ -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 @@ -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. @@ -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( @@ -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( @@ -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() { @@ -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) @@ -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)) @@ -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() @@ -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 @@ -272,11 +369,19 @@ class EmptySessionPanel( repaint() } - private fun welcomeHtml() = XmlStringUtil.wrapInHtml( - "
${XmlStringUtil.escapeString(KiloBundle.message("session.empty.welcome"))}
" + /** [body] must already be escaped or generated HTML — this only wraps and centers it. */ + private fun centeredHtml(body: String) = XmlStringUtil.wrapInHtml( + "
$body
" ) 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" } } 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 93c9e4f31f..fc17b67220 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -28,6 +28,10 @@ session.connection.warning.config=Configuration warnings notification.group.kilo=Kilo Code session.empty.welcome=Kilo Code is an AI coding assistant. Ask it to build features, fix bugs, or explain your codebase. +session.empty.branch=You''re working directly on {0}. Start a task, or {1} to keep changes isolated. +session.empty.branch.link=run it in a worktree +session.empty.worktree=You''re in an isolated worktree on {0}. Work freely — your main checkout stays untouched. +session.empty.worktree.unknown=You're in an isolated worktree. Work freely — your main checkout stays untouched. session.account.balance=Balance: {0} session.account.switcher=Switch account session.empty.loading=Loading... diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties index 55522110ac..89f1fe80ab 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties @@ -17,6 +17,10 @@ session.connection.unsupported.options=الخيار 1: افتح المشروع session.connection.warning.config=تحذيرات التكوين session.empty.welcome=Kilo Code هو مساعد برمجة بالذكاء الاصطناعي. اطلب منه بناء ميزات أو إصلاح أخطاء أو شرح قاعدة الكود. +session.empty.branch=تعمل مباشرةً على {0}. ابدأ مهمة، أو {1} لإبقاء التغييرات معزولة. +session.empty.branch.link=شغّلها في worktree +session.empty.worktree=أنت في worktree معزول على {0}. اعمل بحرية — تبقى نسختك الرئيسية دون تغيير. +session.empty.worktree.unknown=أنت في worktree معزول. اعمل بحرية — تبقى نسختك الرئيسية دون تغيير. session.empty.loading=جاري التحميل… session.empty.recent=الحديثة session.showHistory=عرض السجل @@ -163,6 +167,8 @@ action.Kilo.Settings.text=الإعدادات action.Kilo.Settings.description=إعدادات Kilo Code action.Kilo.NewSession.text=جلسة جديدة action.Kilo.NewSession.description=بدء جلسة Kilo جديدة +action.Kilo.NewSession.toolbar=جلسة +action.Kilo.NewWorktree.toolbar=Worktree action.Kilo.History.text=السجل action.Kilo.History.description=عرض سجل الجلسات action.Kilo.SendPrompt.text=إرسال الطلب diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties index 301f8c2fec..75aeba15c3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties @@ -17,6 +17,10 @@ session.connection.unsupported.options=Opcija 1: Otvorite projekat u kontejneru session.connection.warning.config=Upozorenja konfiguracije session.empty.welcome=Kilo Code je AI asistent za kodiranje. Zatražite od njega da gradi funkcije, ispravlja greške ili objašnjava vašu bazu koda. +session.empty.branch=Radite direktno na {0}. Započnite zadatak ili {1} da izmjene ostanu izolovane. +session.empty.branch.link=pokrenite ga u worktree-u +session.empty.worktree=Nalazite se u izolovanom worktree-u na {0}. Radite slobodno — vaš glavni checkout ostaje nepromijenjen. +session.empty.worktree.unknown=Nalazite se u izolovanom worktree-u. Radite slobodno — vaš glavni checkout ostaje nepromijenjen. session.empty.loading=Učitavanje… session.empty.recent=NEDAVNO session.showHistory=Prikaži historiju @@ -163,6 +167,8 @@ action.Kilo.Settings.text=Postavke action.Kilo.Settings.description=Postavke Kilo Code action.Kilo.NewSession.text=Nova sesija action.Kilo.NewSession.description=Pokrenite novu Kilo sesiju +action.Kilo.NewSession.toolbar=Sesija +action.Kilo.NewWorktree.toolbar=Worktree action.Kilo.History.text=Historija action.Kilo.History.description=Prikaži historiju sesija action.Kilo.SendPrompt.text=Pošalji upit diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties index f10dc7e7b8..d447386eb1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties @@ -17,6 +17,10 @@ session.connection.unsupported.options=Mulighed 1: Åbn projektet i containeren session.connection.warning.config=Konfigurationsadvarsler session.empty.welcome=Kilo Code er en AI-kodningsassistent. Bed den om at bygge funktioner, rette fejl eller forklare din kodebase. +session.empty.branch=Du arbejder direkte på {0}. Start en opgave, eller {1} for at holde ændringer isoleret. +session.empty.branch.link=kør den i et worktree +session.empty.worktree=Du er i et isoleret worktree på {0}. Arbejd frit — dit primære checkout forbliver urørt. +session.empty.worktree.unknown=Du er i et isoleret worktree. Arbejd frit — dit primære checkout forbliver urørt. session.empty.loading=Indlæser… session.empty.recent=SENESTE session.showHistory=Vis historik @@ -163,6 +167,8 @@ action.Kilo.Settings.text=Indstillinger action.Kilo.Settings.description=Kilo Code-indstillinger action.Kilo.NewSession.text=Ny session action.Kilo.NewSession.description=Start en ny Kilo-session +action.Kilo.NewSession.toolbar=Session +action.Kilo.NewWorktree.toolbar=Worktree action.Kilo.History.text=Historik action.Kilo.History.description=Vis sessionshistorik action.Kilo.SendPrompt.text=Send prompt diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties index 7df185f3ae..e7127d8cda 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties @@ -17,6 +17,10 @@ session.connection.unsupported.options=Option 1: Öffnen Sie das Projekt im Cont session.connection.warning.config=Konfigurationswarnungen session.empty.welcome=Kilo Code ist ein KI-Coding-Assistent. Bitten Sie ihn, Funktionen zu erstellen, Fehler zu beheben oder Ihre Codebasis zu erklären. +session.empty.branch=Sie arbeiten direkt auf {0}. Starten Sie eine Aufgabe oder {1}, um Änderungen isoliert zu halten. +session.empty.branch.link=führen Sie sie in einem Worktree aus +session.empty.worktree=Sie sind in einem isolierten Worktree auf {0}. Arbeiten Sie frei — Ihr Haupt-Checkout bleibt unberührt. +session.empty.worktree.unknown=Sie sind in einem isolierten Worktree. Arbeiten Sie frei — Ihr Haupt-Checkout bleibt unberührt. session.empty.loading=Wird geladen… session.empty.recent=ZULETZT session.showHistory=Verlauf anzeigen @@ -163,6 +167,8 @@ action.Kilo.Settings.text=Einstellungen action.Kilo.Settings.description=Kilo Code Einstellungen action.Kilo.NewSession.text=Neue Sitzung action.Kilo.NewSession.description=Neue Kilo-Sitzung starten +action.Kilo.NewSession.toolbar=Sitzung +action.Kilo.NewWorktree.toolbar=Worktree action.Kilo.History.text=Verlauf action.Kilo.History.description=Sitzungsverlauf anzeigen action.Kilo.SendPrompt.text=Anfrage senden diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties index 8b6d662355..8d408f7323 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties @@ -17,6 +17,10 @@ session.connection.unsupported.options=Opción 1: Abre el proyecto en el contene session.connection.warning.config=Advertencias de configuración session.empty.welcome=Kilo Code es un asistente de codificación con IA. Pida que construya funciones, corrija errores o explique su base de código. +session.empty.branch=Estás trabajando directamente en {0}. Inicia una tarea o {1} para mantener los cambios aislados. +session.empty.branch.link=ejecútala en un worktree +session.empty.worktree=Estás en un worktree aislado en {0}. Trabaja con libertad — tu checkout principal no se toca. +session.empty.worktree.unknown=Estás en un worktree aislado. Trabaja con libertad — tu checkout principal no se toca. session.empty.loading=Cargando… session.empty.recent=RECIENTE session.showHistory=Mostrar historial @@ -163,6 +167,8 @@ action.Kilo.Settings.text=Configuración action.Kilo.Settings.description=Configuración de Kilo Code action.Kilo.NewSession.text=Nueva sesión action.Kilo.NewSession.description=Iniciar una nueva sesión de Kilo +action.Kilo.NewSession.toolbar=Sesión +action.Kilo.NewWorktree.toolbar=Worktree action.Kilo.History.text=Historial action.Kilo.History.description=Mostrar el historial de sesiones action.Kilo.SendPrompt.text=Enviar mensaje diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties index 40c1aed80c..3bea71d199 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties @@ -17,6 +17,10 @@ session.connection.unsupported.options=Option 1 : ouvrez le projet dans le conte session.connection.warning.config=Avertissements de configuration session.empty.welcome=Kilo Code est un assistant de codage IA. Demandez-lui de créer des fonctionnalités, corriger des bugs ou expliquer votre base de code. +session.empty.branch=Vous travaillez directement sur {0}. Lancez une tâche ou {1} pour isoler vos modifications. +session.empty.branch.link=exécutez-la dans un worktree +session.empty.worktree=Vous êtes dans un worktree isolé sur {0}. Travaillez librement — votre checkout principal reste intact. +session.empty.worktree.unknown=Vous êtes dans un worktree isolé. Travaillez librement — votre checkout principal reste intact. session.empty.loading=Chargement… session.empty.recent=RÉCENT session.showHistory=Afficher l'historique @@ -163,6 +167,8 @@ action.Kilo.Settings.text=Paramètres action.Kilo.Settings.description=Paramètres de Kilo Code action.Kilo.NewSession.text=Nouvelle session action.Kilo.NewSession.description=Démarrer une nouvelle session Kilo +action.Kilo.NewSession.toolbar=Session +action.Kilo.NewWorktree.toolbar=Worktree action.Kilo.History.text=Historique action.Kilo.History.description=Afficher l'historique des sessions action.Kilo.SendPrompt.text=Envoyer l'invite diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties index d1e39040ee..c6e0aaf577 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties @@ -17,6 +17,10 @@ session.connection.unsupported.options=オプション1: JetBrains Gateway を session.connection.warning.config=設定の警告 session.empty.welcome=Kilo CodeはAIコーディングアシスタントです。機能の作成、バグの修正、またはコードベースの説明を依頼できます。 +session.empty.branch={0} で直接作業しています。タスクを開始するか、{1}すると変更を隔離できます。 +session.empty.branch.link=worktree で実行 +session.empty.worktree={0} の独立した worktree にいます。メインのチェックアウトには影響しないので自由に作業できます。 +session.empty.worktree.unknown=独立した worktree にいます。メインのチェックアウトには影響しないので自由に作業できます。 session.empty.loading=読み込み中… session.empty.recent=最近 session.showHistory=履歴を表示 @@ -163,6 +167,8 @@ action.Kilo.Settings.text=設定 action.Kilo.Settings.description=Kilo Codeの設定 action.Kilo.NewSession.text=新しいセッション action.Kilo.NewSession.description=新しいKiloセッションを開始 +action.Kilo.NewSession.toolbar=セッション +action.Kilo.NewWorktree.toolbar=Worktree action.Kilo.History.text=履歴 action.Kilo.History.description=セッション履歴を表示 action.Kilo.SendPrompt.text=プロンプトを送信 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties index 78c97e633f..419721fabe 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties @@ -17,6 +17,10 @@ session.connection.unsupported.options=옵션 1: JetBrains Gateway로 컨테이 session.connection.warning.config=구성 경고 session.empty.welcome=Kilo Code는 AI 코딩 어시스턴트입니다. 기능 구축, 버그 수정, 코드베이스 설명을 요청하세요. +session.empty.branch={0}에서 직접 작업하고 있습니다. 작업을 시작하거나 {1}하여 변경 사항을 분리하세요. +session.empty.branch.link=worktree에서 실행 +session.empty.worktree={0}의 격리된 worktree에 있습니다. 메인 체크아웃은 영향을 받지 않으니 자유롭게 작업하세요. +session.empty.worktree.unknown=격리된 worktree에 있습니다. 메인 체크아웃은 영향을 받지 않으니 자유롭게 작업하세요. session.empty.loading=로딩 중… session.empty.recent=최근 session.showHistory=기록 보기 @@ -163,6 +167,8 @@ action.Kilo.Settings.text=설정 action.Kilo.Settings.description=Kilo Code 설정 action.Kilo.NewSession.text=새 세션 action.Kilo.NewSession.description=새 Kilo 세션 시작 +action.Kilo.NewSession.toolbar=세션 +action.Kilo.NewWorktree.toolbar=Worktree action.Kilo.History.text=기록 action.Kilo.History.description=세션 기록 표시 action.Kilo.SendPrompt.text=프롬프트 전송 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties index ea86c878b0..4af910037b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties @@ -17,6 +17,10 @@ session.connection.unsupported.options=Optie 1: Open het project in de container session.connection.warning.config=Configuratiewaarschuwingen session.empty.welcome=Kilo Code is een AI-codeerassistent. Vraag het om functies te bouwen, bugs te repareren of uw codebase uit te leggen. +session.empty.branch=Je werkt direct op {0}. Start een taak of {1} om wijzigingen geïsoleerd te houden. +session.empty.branch.link=voer hem uit in een worktree +session.empty.worktree=Je zit in een geïsoleerde worktree op {0}. Werk vrij — je hoofdcheckout blijft ongemoeid. +session.empty.worktree.unknown=Je zit in een geïsoleerde worktree. Werk vrij — je hoofdcheckout blijft ongemoeid. session.empty.loading=Laden… session.empty.recent=RECENT session.showHistory=Geschiedenis weergeven @@ -163,6 +167,8 @@ action.Kilo.Settings.text=Instellingen action.Kilo.Settings.description=Kilo Code-instellingen action.Kilo.NewSession.text=Nieuwe sessie action.Kilo.NewSession.description=Een nieuwe Kilo-sessie starten +action.Kilo.NewSession.toolbar=Sessie +action.Kilo.NewWorktree.toolbar=Worktree action.Kilo.History.text=Geschiedenis action.Kilo.History.description=Sessiegeschiedenis weergeven action.Kilo.SendPrompt.text=Prompt verzenden diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties index d7d4d01344..73d2b6ee80 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties @@ -17,6 +17,10 @@ session.connection.unsupported.options=Alternativ 1: Åpne prosjektet i containe session.connection.warning.config=Konfigurasjonsadvarsler session.empty.welcome=Kilo Code er en AI-kodingsassistent. Be den om å bygge funksjoner, fikse feil eller forklare kodebasen din. +session.empty.branch=Du jobber direkte på {0}. Start en oppgave, eller {1} for å holde endringene isolert. +session.empty.branch.link=kjør den i et worktree +session.empty.worktree=Du er i et isolert worktree på {0}. Jobb fritt — hovedutsjekkingen din er urørt. +session.empty.worktree.unknown=Du er i et isolert worktree. Jobb fritt — hovedutsjekkingen din er urørt. session.empty.loading=Laster… session.empty.recent=NYLIGE session.showHistory=Vis historikk @@ -168,6 +172,8 @@ action.Kilo.Settings.text=Innstillinger action.Kilo.Settings.description=Kilo Code-innstillinger action.Kilo.NewSession.text=Ny økt action.Kilo.NewSession.description=Start en ny Kilo-økt +action.Kilo.NewSession.toolbar=Økt +action.Kilo.NewWorktree.toolbar=Worktree action.Kilo.History.text=Historikk action.Kilo.History.description=Vis økthistorikk action.Kilo.SendPrompt.text=Send forespørsel diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties index 140f89f54b..d75874e8d8 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties @@ -17,6 +17,10 @@ session.connection.unsupported.options=Opcja 1: Otwórz projekt w kontenerze lub session.connection.warning.config=Ostrzeżenia konfiguracji session.empty.welcome=Kilo Code to asystent kodowania AI. Poproś go o tworzenie funkcji, naprawianie błędów lub wyjaśnianie bazy kodu. +session.empty.branch=Pracujesz bezpośrednio na {0}. Rozpocznij zadanie lub {1}, aby odizolować zmiany. +session.empty.branch.link=uruchom je w worktree +session.empty.worktree=Jesteś w odizolowanym worktree na {0}. Pracuj swobodnie — twój główny checkout pozostaje nietknięty. +session.empty.worktree.unknown=Jesteś w odizolowanym worktree. Pracuj swobodnie — twój główny checkout pozostaje nietknięty. session.empty.loading=Ładowanie… session.empty.recent=OSTATNIE session.showHistory=Pokaż historię @@ -168,6 +172,8 @@ action.Kilo.Settings.text=Ustawienia action.Kilo.Settings.description=Ustawienia Kilo Code action.Kilo.NewSession.text=Nowa sesja action.Kilo.NewSession.description=Rozpocznij nową sesję Kilo +action.Kilo.NewSession.toolbar=Sesja +action.Kilo.NewWorktree.toolbar=Worktree action.Kilo.History.text=Historia action.Kilo.History.description=Pokaż historię sesji action.Kilo.SendPrompt.text=Wyślij monit diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties index ed51cb5a9b..d39ebc987d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties @@ -17,6 +17,10 @@ session.connection.unsupported.options=Opção 1: Abra o projeto no contêiner o session.connection.warning.config=Avisos de configuração session.empty.welcome=Kilo Code é um assistente de codificação com IA. Peça que construa funcionalidades, corrija bugs ou explique sua base de código. +session.empty.branch=Você está trabalhando diretamente em {0}. Inicie uma tarefa ou {1} para manter as alterações isoladas. +session.empty.branch.link=execute-a em um worktree +session.empty.worktree=Você está em um worktree isolado em {0}. Trabalhe livremente — seu checkout principal permanece intacto. +session.empty.worktree.unknown=Você está em um worktree isolado. Trabalhe livremente — seu checkout principal permanece intacto. session.empty.loading=Carregando… session.empty.recent=RECENTE session.showHistory=Mostrar histórico @@ -168,6 +172,8 @@ action.Kilo.Settings.text=Configurações action.Kilo.Settings.description=Configurações do Kilo Code action.Kilo.NewSession.text=Nova sessão action.Kilo.NewSession.description=Iniciar uma nova sessão do Kilo +action.Kilo.NewSession.toolbar=Sessão +action.Kilo.NewWorktree.toolbar=Worktree action.Kilo.History.text=Histórico action.Kilo.History.description=Exibir histórico de sessões action.Kilo.SendPrompt.text=Enviar prompt diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties index 94c8e65c0d..94735d04b8 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties @@ -17,6 +17,10 @@ session.connection.unsupported.options=Вариант 1: откройте про session.connection.warning.config=Предупреждения конфигурации session.empty.welcome=Kilo Code — это AI-ассистент по программированию. Попросите его разработать функции, исправить ошибки или объяснить кодовую базу. +session.empty.branch=Вы работаете напрямую в {0}. Начните задачу или {1}, чтобы изолировать изменения. +session.empty.branch.link=запустите её в worktree +session.empty.worktree=Вы в изолированном worktree на {0}. Работайте свободно — основная рабочая копия не затрагивается. +session.empty.worktree.unknown=Вы в изолированном worktree. Работайте свободно — основная рабочая копия не затрагивается. session.empty.loading=Загрузка… session.empty.recent=НЕДАВНИЕ session.showHistory=Показать историю @@ -168,6 +172,8 @@ action.Kilo.Settings.text=Настройки action.Kilo.Settings.description=Настройки Kilo Code action.Kilo.NewSession.text=Новая сессия action.Kilo.NewSession.description=Запустить новую сессию Kilo +action.Kilo.NewSession.toolbar=Сессия +action.Kilo.NewWorktree.toolbar=Worktree action.Kilo.History.text=История action.Kilo.History.description=Показать историю сессий action.Kilo.SendPrompt.text=Отправить запрос diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties index 031d159ed1..666f666268 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties @@ -17,6 +17,10 @@ session.connection.unsupported.options=ตัวเลือกที่ 1: เ session.connection.warning.config=คำเตือนการกำหนดค่า session.empty.welcome=Kilo Code คือผู้ช่วยเขียนโค้ด AI ขอให้สร้างฟีเจอร์แก้สันข้อผิดพลาด หรืออธิบายโค้ดเบสของคุณ +session.empty.branch=คุณกำลังทำงานบน {0} โดยตรง เริ่มงานใหม่ หรือ {1} เพื่อแยกการเปลี่ยนแปลงออกจากกัน +session.empty.branch.link=รันใน worktree +session.empty.worktree=คุณอยู่ใน worktree ที่แยกอิสระบน {0} ทำงานได้อย่างอิสระ — เช็คเอาต์หลักของคุณไม่ถูกแตะต้อง +session.empty.worktree.unknown=คุณอยู่ใน worktree ที่แยกอิสระ ทำงานได้อย่างอิสระ — เช็คเอาต์หลักของคุณไม่ถูกแตะต้อง session.empty.loading=กำลังโหลด… session.empty.recent=ล่าสุด session.showHistory=แสดงประวัติ @@ -168,6 +172,8 @@ action.Kilo.Settings.text=การตั้งค่า action.Kilo.Settings.description=การตั้งค่า Kilo Code action.Kilo.NewSession.text=เซสชันใหม่ action.Kilo.NewSession.description=เริ่มเซสชัน Kilo ใหม่ +action.Kilo.NewSession.toolbar=เซสชัน +action.Kilo.NewWorktree.toolbar=Worktree action.Kilo.History.text=ประวัติ action.Kilo.History.description=แสดงประวัติเซสชัน action.Kilo.SendPrompt.text=ส่งคำขอ diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties index edfdcafd34..d5ae24c989 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties @@ -17,6 +17,10 @@ session.connection.unsupported.options=Seçenek 1: Projeyi JetBrains Gateway ile session.connection.warning.config=Yapılandırma uyarıları session.empty.welcome=Kilo Code, bir yapay zeka kodlama asistanıdır. Özellik oluşturmasını, hata düzeltirlmesi veya kod tabanınızı açıklamasını isteyin. +session.empty.branch=Doğrudan {0} üzerinde çalışıyorsunuz. Bir görev başlatın ya da değişiklikleri izole tutmak için {1}. +session.empty.branch.link=bir worktree içinde çalıştırın +session.empty.worktree={0} üzerinde izole bir worktree içindesiniz. Rahatça çalışın — ana checkout''unuz olduğu gibi kalır. +session.empty.worktree.unknown=İzole bir worktree içindesiniz. Rahatça çalışın — ana checkout'unuz olduğu gibi kalır. session.empty.loading=Yükleniyor… session.empty.recent=SON session.showHistory=Geçmişi göster @@ -168,6 +172,8 @@ action.Kilo.Settings.text=Ayarlar action.Kilo.Settings.description=Kilo Code ayarları action.Kilo.NewSession.text=Yeni oturum action.Kilo.NewSession.description=Yeni bir Kilo oturumu başlat +action.Kilo.NewSession.toolbar=Oturum +action.Kilo.NewWorktree.toolbar=Worktree action.Kilo.History.text=Geçmiş action.Kilo.History.description=Oturum geçmişini göster action.Kilo.SendPrompt.text=İstem gönder diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties index 51b50da55d..10a0ff9316 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties @@ -17,6 +17,10 @@ session.connection.unsupported.options=Варіант 1: відкрийте пр session.connection.warning.config=Попередження конфігурації session.empty.welcome=Kilo Code — це AI-асистент для програмування. Попросіть його створити функції, виправити помилки або пояснити ваш код. +session.empty.branch=Ви працюєте безпосередньо в {0}. Почніть завдання або {1}, щоб ізолювати зміни. +session.empty.branch.link=запустіть його в worktree +session.empty.worktree=Ви в ізольованому worktree на {0}. Працюйте вільно — основна робоча копія залишається незмінною. +session.empty.worktree.unknown=Ви в ізольованому worktree. Працюйте вільно — основна робоча копія залишається незмінною. session.empty.loading=Завантаження… session.empty.recent=НЕДАВНІ session.showHistory=Показати історію @@ -163,6 +167,8 @@ action.Kilo.Settings.text=Налаштування action.Kilo.Settings.description=Налаштування Kilo Code action.Kilo.NewSession.text=Нова сесія action.Kilo.NewSession.description=Розпочати нову сесію Kilo +action.Kilo.NewSession.toolbar=Сесія +action.Kilo.NewWorktree.toolbar=Worktree action.Kilo.History.text=Історія action.Kilo.History.description=Показати історію сесій action.Kilo.SendPrompt.text=Надіслати запит diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties index 7c79292775..8de4b4f4e8 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties @@ -17,6 +17,10 @@ session.connection.unsupported.options=选项 1:使用 JetBrains Gateway 在 session.connection.warning.config=配置警告 session.empty.welcome=Kilo Code 是一个 AI 编程助手。可请它构建功能、修复错误或解释您的代码库。 +session.empty.branch=你正在 {0} 上直接工作。开始一个任务,或{1}以隔离改动。 +session.empty.branch.link=在 worktree 中运行 +session.empty.worktree=你在 {0} 的独立 worktree 中。可以放心工作 — 主工作副本不会受到影响。 +session.empty.worktree.unknown=你在独立的 worktree 中。可以放心工作 — 主工作副本不会受到影响。 session.empty.loading=加载中… session.empty.recent=最近 session.showHistory=显示历史 @@ -163,6 +167,8 @@ action.Kilo.Settings.text=设置 action.Kilo.Settings.description=Kilo Code 设置 action.Kilo.NewSession.text=新建会话 action.Kilo.NewSession.description=开始新的 Kilo 会话 +action.Kilo.NewSession.toolbar=会话 +action.Kilo.NewWorktree.toolbar=Worktree action.Kilo.History.text=历史 action.Kilo.History.description=显示会话历史 action.Kilo.SendPrompt.text=发送提示 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties index c135ded064..85cda84a91 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties @@ -17,6 +17,10 @@ session.connection.unsupported.options=選項 1:使用 JetBrains Gateway 在 session.connection.warning.config=設定警告 session.empty.welcome=Kilo Code 是 AI 程式輔助。可請它建置功能、修復錯誤或解釋您的程式程式庫。 +session.empty.branch=你正在 {0} 上直接工作。開始一個任務,或{1}以隔離變更。 +session.empty.branch.link=在 worktree 中執行 +session.empty.worktree=你在 {0} 的獨立 worktree 中。可以放心工作 — 主工作副本不會受到影響。 +session.empty.worktree.unknown=你在獨立的 worktree 中。可以放心工作 — 主工作副本不會受到影響。 session.empty.loading=載入中… session.empty.recent=最近 session.showHistory=顯示歷史 @@ -163,6 +167,8 @@ action.Kilo.Settings.text=設定 action.Kilo.Settings.description=Kilo Code 設定 action.Kilo.NewSession.text=新建工作階段 action.Kilo.NewSession.description=開始新的 Kilo 工作階段 +action.Kilo.NewSession.toolbar=會話 +action.Kilo.NewWorktree.toolbar=Worktree action.Kilo.History.text=歷史 action.Kilo.History.description=顯示工作階段歷史 action.Kilo.SendPrompt.text=發送提示 diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/plugin/KiloBundleLocaleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/plugin/KiloBundleLocaleTest.kt new file mode 100644 index 0000000000..fa882be3f2 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/plugin/KiloBundleLocaleTest.kt @@ -0,0 +1,100 @@ +package ai.kilocode.client.plugin + +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.io.InputStreamReader +import java.nio.charset.StandardCharsets +import java.text.MessageFormat +import java.util.Locale +import java.util.Properties + +/** + * Guards the localized empty-session tip and toolbar labels. + * + * The tip keys carry `{0}`/`{1}`, so they go through [MessageFormat], where a lone apostrophe + * silently swallows the surrounding text and `''` collapses to one. Translations are easy to get + * wrong here, so every locale is formatted for real rather than compared as a raw string. + */ +class KiloBundleLocaleTest : BasePlatformTestCase() { + fun `test parameterized tips format cleanly in every locale`() { + for (locale in LOCALES) { + val props = load(locale) + + val branch = props.getProperty("session.empty.branch") + assertNotNull("$locale: missing session.empty.branch", branch) + assertEscaped(locale, "session.empty.branch", branch!!) + val rendered = format(branch, "main", "LINK_PHRASE") + assertTrue("$locale: branch tip dropped the branch name -> $rendered", rendered.contains("main")) + assertTrue("$locale: branch tip dropped the link -> $rendered", rendered.contains("LINK_PHRASE")) + assertClean(locale, "session.empty.branch", rendered) + + val worktree = props.getProperty("session.empty.worktree") + assertNotNull("$locale: missing session.empty.worktree", worktree) + assertEscaped(locale, "session.empty.worktree", worktree!!) + val tree = format(worktree, "feature/x") + assertTrue("$locale: worktree tip dropped the branch name -> $tree", tree.contains("feature/x")) + assertClean(locale, "session.empty.worktree", tree) + } + } + + fun `test plain keys are present and carry no placeholders`() { + for (locale in LOCALES) { + val props = load(locale) + for (key in PLAIN) { + val value = props.getProperty(key) + assertNotNull("$locale: missing $key", value) + assertTrue("$locale: $key is blank", value!!.isNotBlank()) + assertFalse("$locale: $key should not contain a placeholder -> $value", value.contains("{0}")) + assertFalse( + "$locale: $key has no placeholders so apostrophes must not be doubled -> $value", + value.contains("''"), + ) + } + } + } + + private fun format(pattern: String, vararg args: String) = + MessageFormat(pattern, Locale.ROOT).format(args) + + /** + * Every apostrophe in a MessageFormat pattern must be doubled. A lone one opens a quoted run + * that silently eats itself (and any placeholder it spans), which formatting alone will not + * always reveal — so the raw pattern is checked directly. + */ + private fun assertEscaped(locale: String, key: String, pattern: String) { + for (run in Regex("'+").findAll(pattern)) { + assertTrue( + "$locale: $key has an unescaped apostrophe, double it -> $pattern", + run.value.length % 2 == 0, + ) + } + } + + /** After formatting, MessageFormat has consumed its quoting — leftovers mean a bad pattern. */ + private fun assertClean(locale: String, key: String, rendered: String) { + assertFalse("$locale: $key still has a doubled apostrophe -> $rendered", rendered.contains("''")) + assertFalse("$locale: $key left an unformatted placeholder -> $rendered", rendered.contains("{")) + } + + private fun load(locale: String): Properties { + val name = if (locale == "en") "/messages/KiloBundle.properties" else "/messages/KiloBundle_$locale.properties" + val stream = javaClass.getResourceAsStream(name) + assertNotNull("$locale: $name not on the classpath", stream) + return Properties().apply { + InputStreamReader(stream!!, StandardCharsets.UTF_8).use { load(it) } + } + } + + private companion object { + val LOCALES = listOf( + "en", "ar", "bs", "da", "de", "es", "fr", "ja", "ko", "nl", + "no", "pl", "pt_BR", "ru", "th", "tr", "uk", "zh_CN", "zh_TW", + ) + + val PLAIN = listOf( + "session.empty.branch.link", + "session.empty.worktree.unknown", + "action.Kilo.NewSession.toolbar", + "action.Kilo.NewWorktree.toolbar", + ) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt index 4d8f105a8b..5840b1fe84 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt @@ -15,6 +15,8 @@ import ai.kilocode.client.ui.FilledBadgeIcon import ai.kilocode.client.testing.FakeAppRpcApi import ai.kilocode.client.testing.FakeSessionRpcApi import ai.kilocode.client.testing.FakeWorkspaceRpcApi +import ai.kilocode.rpc.dto.BranchStatusDto +import ai.kilocode.rpc.dto.GhAvailability import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto @@ -34,6 +36,8 @@ import kotlinx.coroutines.runBlocking import java.awt.BorderLayout import java.awt.Cursor import javax.swing.JButton +import javax.swing.JEditorPane +import javax.swing.event.HyperlinkEvent @Suppress("UnstableApiUsage") class EmptySessionPanelTest : BasePlatformTestCase() { @@ -159,6 +163,135 @@ class EmptySessionPanelTest : BasePlatformTestCase() { ) } + // ---- branch/worktree tip under the logo ---- + + fun `test no tip until branch status arrives`() { + val panel = panel(newWorktree = {}) + + assertNull(panel.tipText()) + assertFalse(panel.worktreeLinked()) + assertEquals(panel.explanationText(), panel.descriptionText()) + } + + fun `test branch promotes running the task in a worktree`() { + val panel = panel(newWorktree = {}) + + panel.setBranch(BranchStatusDto(branch = "main", worktree = false)) + + assertEquals( + "You're working directly on main. Start a task, or run it in a worktree to keep changes isolated.", + panel.tipText(), + ) + assertTrue(panel.worktreeLinked()) + } + + fun `test worktree hints isolation and links nothing`() { + val panel = panel(newWorktree = {}) + + panel.setBranch(BranchStatusDto(branch = "feature/x", worktree = true)) + + assertEquals( + "You're in an isolated worktree on feature/x. Work freely — your main checkout stays untouched.", + panel.tipText(), + ) + assertFalse(panel.worktreeLinked()) + } + + fun `test worktree without a branch name falls back to the generic worktree tip`() { + val panel = panel() + + panel.setBranch(BranchStatusDto(branch = "(detached)", worktree = true)) + + assertEquals( + "You're in an isolated worktree. Work freely — your main checkout stays untouched.", + panel.tipText(), + ) + } + + fun `test detached plain checkout keeps the generic welcome`() { + val panel = panel(newWorktree = {}) + + panel.setBranch(BranchStatusDto(branch = "(detached)", worktree = false)) + + assertNull(panel.tipText()) + assertFalse(panel.worktreeLinked()) + assertEquals(panel.explanationText(), panel.descriptionText()) + } + + fun `test missing git keeps the generic welcome`() { + val panel = panel(newWorktree = {}) + + panel.setBranch( + BranchStatusDto(branch = "main", worktree = false, availability = GhAvailability.GIT_MISSING), + ) + + assertNull(panel.tipText()) + assertFalse(panel.worktreeLinked()) + } + + fun `test long branch name is shortened`() { + val panel = panel() + + panel.setBranch(BranchStatusDto(branch = "feature/a-very-long-branch-name-that-keeps-going")) + + val tip = panel.tipText().orEmpty() + assertTrue(tip, tip.contains("…")) + assertFalse(tip, tip.contains("keeps-going")) + } + + fun `test minimal surface shows a worktree tip but no generic welcome`() { + val panel = panel(minimal = true) + + assertFalse(panel.descriptionVisible()) + + panel.setBranch(BranchStatusDto(branch = "feature/x", worktree = true)) + + assertTrue(panel.descriptionVisible()) + } + + fun `test phrase stays plain text without a callback`() { + val panel = panel() + + panel.setBranch(BranchStatusDto(branch = "main", worktree = false)) + + assertEquals( + "You're working directly on main. Start a task, or run it in a worktree to keep changes isolated.", + panel.tipText(), + ) + assertFalse(panel.worktreeLinked()) + } + + fun `test activating the inline link invokes the callback`() { + var fired = 0 + val panel = panel(newWorktree = { fired++ }) + panel.setBranch(BranchStatusDto(branch = "main", worktree = false)) + + activateLink(panel) + + assertEquals(1, fired) + } + + fun `test activating the inline link ignores other hrefs`() { + var fired = 0 + val panel = panel(newWorktree = { fired++ }) + panel.setBranch(BranchStatusDto(branch = "main", worktree = false)) + + activateLink(panel, href = "https://example.test") + + assertEquals(0, fired) + } + + /** + * Fires the activation through the editor pane that `setCopyable(true)` installs, so the real + * listener wiring is exercised rather than a stand-in. + */ + private fun activateLink(panel: EmptySessionPanel, href: String = panel.worktreeHref()) { + val pane = UIUtil.uiTraverser(panel).filter(JEditorPane::class.java).first() + assertNotNull(pane) + val event = HyperlinkEvent(pane, HyperlinkEvent.EventType.ACTIVATED, null, href) + pane!!.hyperlinkListeners.forEach { it.hyperlinkUpdate(event) } + } + fun `test selecting recent session does not open it`() { val panel = panel(listOf(session("ses_1"), session("ses_2"))) @@ -373,7 +506,17 @@ class EmptySessionPanelTest : BasePlatformTestCase() { activity: () -> Map = { sessions.activitySnapshot() }, titles: () -> Map = { emptyMap() }, minimal: Boolean = false, - ) = EmptySessionPanel(testRootDisposable, controller, recents, history, activity, titles, minimal = minimal) + newWorktree: (() -> Unit)? = null, + ) = EmptySessionPanel( + testRootDisposable, + controller, + recents, + history, + activity, + titles, + minimal = minimal, + newWorktree = newWorktree, + ) private fun flush() = runBlocking { delay(100) From dfaa30b0b0fc79d2d048da6b8c94039c9d94476f Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 27 Aug 2026 17:18:22 -0400 Subject: [PATCH 3/4] feat(jetbrains): toggle the worktree session list Add a toggle left of the worktree editor toolbar that shows or hides the session list. The choice is persisted per worktree through the backend instead of a single global property, so each worktree reopens the way the user left it. While the list is hidden the toggle carries the session count, or the activity icon of a background session that needs the user, so a pending question stays visible when collapsed. Give the toolbar strip standard horizontal-toolbar padding on its left, top and bottom, keeping the right edge flush so the divider still sits against the header content, and separate the toggle from the toolbar actions with a vertical separator. The toggle is centred at its own height rather than tracking the strip, so its hover box matches a regular toolbar button instead of running edge to edge. --- .../jetbrains-worktree-session-list-toggle.md | 5 + .../backend/rpc/KiloWorktreeRpcApiImpl.kt | 76 ++++- .../backend/rpc/KiloWorktreeRpcApiImplTest.kt | 82 ++++++ .../worktree/KiloWorktreeService.kt | 14 + .../agentManager/worktree/WorktreeActivity.kt | 15 + .../worktree/WorktreeSessionEditorPanel.kt | 137 +++++++-- .../worktree/WorktreeSessionListToggle.kt | 165 +++++++++++ .../worktree/WorktreeSessionListVisibility.kt | 29 ++ .../client/plugin/KiloPluginSettings.kt | 11 - .../worktree/WorktreeActivityTest.kt | 27 ++ .../WorktreeSessionEditorPanelTest.kt | 274 ++++++++++++++++-- .../worktree/WorktreeSessionListToggleTest.kt | 101 +++++++ .../WorktreeSessionListVisibilityTest.kt | 98 +++++++ .../client/testing/FakeWorktreeRpcApi.kt | 22 ++ .../ai/kilocode/rpc/KiloWorktreeRpcApi.kt | 9 + 15 files changed, 991 insertions(+), 74 deletions(-) create mode 100644 .changeset/jetbrains-worktree-session-list-toggle.md create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionListToggle.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionListVisibility.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionListToggleTest.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionListVisibilityTest.kt diff --git a/.changeset/jetbrains-worktree-session-list-toggle.md b/.changeset/jetbrains-worktree-session-list-toggle.md new file mode 100644 index 0000000000..d2b5063103 --- /dev/null +++ b/.changeset/jetbrains-worktree-session-list-toggle.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": minor +--- + +Add a toggle to show or hide the session list in a worktree editor tab. The choice is remembered per worktree, and while the list is hidden the toggle shows the session count or flags a session that needs your attention. 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 3cbb16bf42..1f48247934 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 @@ -87,7 +87,7 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { val items = managedWorktrees(parseWorktreeList(res.stdout)) val alive = items.filter { it.main || Files.isDirectory(Path.of(it.path)) } val store = worktreeNameStore(alive) - val state = store?.let { syncWorktreeState(it, worktreePaths(alive)) } ?: WorktreeState() + val state = store?.let { syncWorktreeState(it, worktreePaths(alive), livePaths(alive)) } ?: WorktreeState() val named = overlayWorktreeNames(alive, state.names) WorktreeListDto(orderWorktrees(named, state.worktreeOrder)) } @@ -454,7 +454,7 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { val target = items.firstOrNull { samePath(it.path, path) && !it.main } ?: return@withContext RenameWorktreeResultDto(error = "Worktree not found") return@withContext try { - val state = readWorktreeState(store).reconcile(worktreePaths(items)) + val state = readWorktreeState(store).reconcile(worktreePaths(items), livePaths(items)) val names = state.names.toMutableMap() names[target.path] = title writeWorktreeState(store, state.copy(names = names)) @@ -478,7 +478,7 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { val target = items.firstOrNull { samePath(it.path, path) && !it.main } ?: return@withContext RenameWorktreeResultDto(error = "Worktree not found") return@withContext try { - val state = readWorktreeState(store).reconcile(worktreePaths(items)) + val state = readWorktreeState(store).reconcile(worktreePaths(items), livePaths(items)) val names = state.names.toMutableMap() // Only adopt while the worktree is still default. A recorded name means the user (or a // prior adoption) already titled it, so leave it untouched and report a no-op. @@ -502,7 +502,7 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { val store = worktreeNameStore(items) ?: return@withContext false return@withContext try { val state = readWorktreeState(store) - writeWorktreeState(store, state.copy(worktreeOrder = paths).reconcile(worktreePaths(items))) + writeWorktreeState(store, state.copy(worktreeOrder = paths).reconcile(worktreePaths(items), livePaths(items))) true } catch (e: Exception) { LOG.warn("worktree reorder failed: dir=$directory message=${e.message}", e) @@ -510,6 +510,35 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { } } + override suspend fun sessionList(directory: String): Boolean? = + withContext(Dispatchers.IO) { + val base = Path.of(directory).normalize() + val res = runGit(base, "worktree", "list", "--porcelain") + if (!res.ok) return@withContext null + val items = managedWorktrees(parseWorktreeList(res.stdout)) + val store = worktreeNameStore(items) ?: return@withContext null + val target = items.firstOrNull { samePath(it.path, directory) } ?: return@withContext null + readWorktreeState(store).sessionList[target.path] + } + + override suspend fun setSessionList(directory: String, visible: Boolean): Boolean = + withContext(Dispatchers.IO) { + val base = Path.of(directory).normalize() + val res = runGit(base, "worktree", "list", "--porcelain") + if (!res.ok) return@withContext false + val items = managedWorktrees(parseWorktreeList(res.stdout)) + val store = worktreeNameStore(items) ?: return@withContext false + val target = items.firstOrNull { samePath(it.path, directory) } ?: return@withContext false + return@withContext try { + val state = readWorktreeState(store).reconcile(worktreePaths(items), livePaths(items)) + writeWorktreeState(store, state.copy(sessionList = state.sessionList + (target.path to visible))) + true + } catch (e: Exception) { + LOG.warn("worktree session list state failed: dir=$directory message=${e.message}", e) + false + } + } + private data class Timed(val time: Long, val value: T) private fun runGit(base: Path, vararg args: String): CmdOut = runGit(base, args.toList()) @@ -741,17 +770,26 @@ private const val WORKTREE_NAMES_FILE = "jetbrains.json" private data class WorktreeNamesFile( val names: Map = emptyMap(), val worktreeOrder: List = emptyList(), + val sessionList: Map = emptyMap(), ) internal data class WorktreeState( val names: Map = emptyMap(), val worktreeOrder: List = emptyList(), + val sessionList: Map = emptyMap(), ) { - fun reconcile(paths: List): WorktreeState { + /** + * Drops state for worktrees git no longer reports. Names and order cover linked worktrees only + * ([paths]), while the session list is also kept for the main working tree, which has a worktree + * editor of its own — hence the wider [live] set. + */ + fun reconcile(paths: List, live: List): WorktreeState { val set = paths.toSet() + val all = live.toSet() val order = (worktreeOrder.filter { it in set } + paths.filter { it !in worktreeOrder }).distinct() val next = names.filterKeys { it in set } - return WorktreeState(next, order) + val visible = sessionList.filterKeys { it in all } + return WorktreeState(next, order, visible) } } @@ -843,9 +881,13 @@ internal fun readWorktreeState(file: Path): WorktreeState { return try { val raw = Files.readString(file) val element = json.parseToJsonElement(raw) - if (element is JsonObject && ("names" in element || "worktreeOrder" in element)) { + if (element is JsonObject && ("names" in element || "worktreeOrder" in element || "sessionList" in element)) { val data = json.decodeFromJsonElement(element) - return WorktreeState(data.names.filterValues { it.isNotBlank() }, data.worktreeOrder.filter { it.isNotBlank() }) + return WorktreeState( + data.names.filterValues { it.isNotBlank() }, + data.worktreeOrder.filter { it.isNotBlank() }, + data.sessionList.filterKeys { it.isNotBlank() }, + ) } val names = json.decodeFromJsonElement(codec, element).filterValues { it.isNotBlank() } WorktreeState(names, names.keys.toList()) @@ -856,8 +898,8 @@ internal fun readWorktreeState(file: Path): WorktreeState { } internal fun writeWorktreeNames(file: Path, names: Map) { - val order = readWorktreeState(file).worktreeOrder - writeWorktreeState(file, WorktreeState(names, order)) + val state = readWorktreeState(file) + writeWorktreeState(file, state.copy(names = names)) } internal fun writeWorktreeState(file: Path, state: WorktreeState) { @@ -865,6 +907,7 @@ internal fun writeWorktreeState(file: Path, state: WorktreeState) { val data = WorktreeNamesFile( names = state.names.filterValues { it.isNotBlank() }, worktreeOrder = state.worktreeOrder.filter { it.isNotBlank() }.distinct(), + sessionList = state.sessionList.filterKeys { it.isNotBlank() }, ) val tmp = Files.createTempFile(file.parent, ".worktree-names", ".tmp") try { @@ -879,9 +922,9 @@ internal fun writeWorktreeState(file: Path, state: WorktreeState) { } } -private fun syncWorktreeState(file: Path, paths: List): WorktreeState { +private fun syncWorktreeState(file: Path, paths: List, live: List): WorktreeState { val state = readWorktreeState(file) - val next = state.reconcile(paths) + val next = state.reconcile(paths, live) if (next == state) return next try { writeWorktreeState(file, next) @@ -903,14 +946,19 @@ private fun removeWorktreeState(file: Path, path: String) { val state = readWorktreeState(file) val names = state.names.filterKeys { !samePath(it, path) } val order = state.worktreeOrder.filter { !samePath(it, path) } - if (names == state.names && order == state.worktreeOrder) return - writeWorktreeState(file, state.copy(names = names, worktreeOrder = order)) + val visible = state.sessionList.filterKeys { !samePath(it, path) } + if (names == state.names && order == state.worktreeOrder && visible == state.sessionList) return + writeWorktreeState(file, state.copy(names = names, worktreeOrder = order, sessionList = visible)) } private fun worktreePaths(items: List): List { return items.filter { !it.main }.map { it.path } } +private fun livePaths(items: List): List { + return items.map { it.path } +} + private fun worktreeNameStore(items: List): Path? { val main = items.firstOrNull { it.main } ?: return null return Path.of(main.path).normalize().resolve(".kilo").resolve(WORKTREE_NAMES_FILE) 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 89db0c7d00..13269b8f4c 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 @@ -384,6 +384,88 @@ class KiloWorktreeRpcApiImplTest { assertEquals(listOf(second.path), state.worktreeOrder) } + @Test + fun `session list visibility round trips beside names and order`() { + val file = repo.resolve(".kilo").resolve("jetbrains.json") + val path = "/repo/.kilo/worktrees/feature-x" + + writeWorktreeState(file, WorktreeState(mapOf(path to "Feature"), listOf(path), mapOf(path to true, "/repo" to false))) + + val state = readWorktreeState(file) + assertEquals(mapOf(path to true, "/repo" to false), state.sessionList) + assertEquals(mapOf(path to "Feature"), state.names) + assertEquals(listOf(path), state.worktreeOrder) + + // A file that only ever recorded visibility must not be mistaken for the legacy name map. + Files.writeString(file, """{"sessionList":{"$path":true}}""") + assertEquals(mapOf(path to true), readWorktreeState(file).sessionList) + assertTrue(readWorktreeState(file).names.isEmpty()) + } + + @Test + fun `reconcile drops visibility for vanished worktrees but keeps the main tree`() { + val main = "/repo" + val live = "/repo/.kilo/worktrees/live" + val dead = "/repo/.kilo/worktrees/dead" + val state = WorktreeState(sessionList = mapOf(main to true, live to false, dead to true)) + + val next = state.reconcile(listOf(live), listOf(main, live)) + + assertEquals(mapOf(main to true, live to false), next.sessionList) + } + + @Test + fun `session list visibility is unknown until set and then persists per worktree`() = runBlocking { + initRepo() + val created = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("feature/x")).worktree) + val main = api.list(repo.toString()).worktrees.single { it.main } + + assertNull(api.sessionList(created.path), "a fresh worktree has no stored choice") + assertNull(api.sessionList(main.path)) + + assertTrue(api.setSessionList(created.path, true)) + assertTrue(api.setSessionList(main.path, false)) + + assertEquals(true, api.sessionList(created.path)) + assertEquals(false, api.sessionList(main.path)) + // The main working tree keeps its entry across a list, which reconciles the file. + api.list(repo.toString()) + assertEquals(false, api.sessionList(main.path)) + } + + @Test + fun `create and list never record session list visibility`() = runBlocking { + initRepo() + val created = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("feature/x")).worktree) + + api.list(repo.toString()) + + assertTrue(readWorktreeState(repo.resolve(".kilo").resolve("jetbrains.json")).sessionList.isEmpty()) + assertNull(api.sessionList(created.path)) + } + + @Test + fun `remove prunes session list visibility`() = runBlocking { + initRepo() + val first = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("zebra")).worktree) + val second = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("alpha")).worktree) + assertTrue(api.setSessionList(first.path, true)) + assertTrue(api.setSessionList(second.path, true)) + + assertTrue(api.remove(repo.toString(), first.path, first.branch).ok) + + assertEquals( + mapOf(second.path to true), + readWorktreeState(repo.resolve(".kilo").resolve("jetbrains.json")).sessionList, + ) + } + + @Test + fun `session list visibility reports nothing outside a repo`() = runBlocking { + assertNull(api.sessionList(repo.toString())) + assertFalse(api.setSessionList(repo.toString(), true)) + } + @Test fun `rename persists a custom worktree name and list overlays it`() = runBlocking { initRepo() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/KiloWorktreeService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/KiloWorktreeService.kt index efa8230457..71afcb6a28 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/KiloWorktreeService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/KiloWorktreeService.kt @@ -152,4 +152,18 @@ class KiloWorktreeService internal constructor( LOG.warn("worktree reorder failed for $directory", e) false } + + suspend fun sessionList(directory: String): Boolean? = try { + call { sessionList(directory) } + } catch (e: Exception) { + LOG.warn("worktree session list state failed for $directory", e) + null + } + + suspend fun setSessionList(directory: String, visible: Boolean): Boolean = try { + call { setSessionList(directory, visible) } + } catch (e: Exception) { + LOG.warn("worktree session list state write failed for $directory", e) + false + } } 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 63321fc5be..0e53236b48 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 @@ -12,6 +12,21 @@ internal fun aggregateWorktreeActivity( internal fun normalizeWorktreePath(path: String): String = normalize(path) +/** + * Activity a collapsed session list should surface: the top-ranked session that needs the user, from + * sessions other than [current] (whose state the open chat already shows) and not being deleted. + * Running work is not an attention state — it would only put a spinner in the header. + */ +internal fun attention( + activity: Map, + current: String? = null, + deleting: Set = emptySet(), +): SessionActivityKind? = activity + .filterKeys { it != current && it !in deleting } + .values + .filter { it != SessionActivityKind.RUNNING } + .minByOrNull(::rank) + private fun normalize(path: String): String = path.trimEnd('/') /** 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 234f3ad596..dcae53acf6 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 @@ -11,7 +11,6 @@ import ai.kilocode.client.session.SessionRef import ai.kilocode.client.session.history.HistorySection import ai.kilocode.client.session.history.HistoryTime import ai.kilocode.client.session.history.LocalHistoryItem -import ai.kilocode.client.plugin.KiloPluginSettings import ai.kilocode.client.telemetry.Telemetry import ai.kilocode.client.ui.list.ActiveList import ai.kilocode.client.ui.list.ActiveListBadge @@ -25,7 +24,11 @@ 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.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.client.ui.UiStyle import ai.kilocode.client.vfs.KiloVfsManager import ai.kilocode.log.KiloLog import ai.kilocode.rpc.dto.SessionDto @@ -48,7 +51,6 @@ import com.intellij.openapi.components.service import com.intellij.openapi.project.Project import com.intellij.openapi.project.ProjectManager import com.intellij.openapi.util.Disposer -import com.intellij.openapi.util.IconLoader import com.intellij.openapi.util.Key import com.intellij.openapi.util.io.FileUtil import com.intellij.openapi.wm.IdeFocusManager @@ -60,6 +62,8 @@ import com.intellij.ui.OnePixelSplitter import com.intellij.ui.SideBorder import com.intellij.ui.awt.RelativePoint import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBInsets +import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil import com.intellij.util.ui.components.BorderLayoutPanel import org.jetbrains.plugins.terminal.TerminalToolWindowFactory @@ -67,9 +71,11 @@ import java.awt.BorderLayout import java.awt.Color import java.awt.Frame import javax.swing.JComponent -import javax.swing.Icon +import javax.swing.JSeparator import javax.swing.ListSelectionModel import javax.swing.JPanel +import javax.swing.SwingConstants +import javax.swing.border.Border import javax.swing.event.ListDataEvent import javax.swing.event.ListDataListener @@ -82,12 +88,19 @@ class WorktreeSessionEditorPanel( private val confirm: ((RelativePoint, ActiveListDeleteOptions, () -> Unit) -> Unit)? = null, private val edit: ((RelativePoint, ActiveListEditOptions, (String) -> Unit) -> Unit)? = null, private val openWorktree: ((String) -> Unit)? = null, + // Persisted per-worktree session list visibility; null means the user has not chosen yet. + private val load: ((Boolean?) -> Unit) -> Unit = { done -> + service().load(worktree.directory, done) + }, + private val save: (Boolean) -> Unit = { value -> + service().save(worktree.directory, value) + }, ) : BorderLayoutPanel(), Disposable, UiDataProvider { private val add = NewAction() private val rename = RenameAction() private val delete = DeleteAction() - private val toggle = ToggleAction() - private val toolbar = ActionManager.getInstance().createActionToolbar(ActionPlaces.TOOLBAR, DefaultActionGroup(toggle, add, rename, delete), true) + private val toggle = WorktreeSessionListToggle { flip() } + private val toolbar = ActionManager.getInstance().createActionToolbar(ActionPlaces.TOOLBAR, DefaultActionGroup(add, rename, delete), true) private val group = ActionManager.getInstance().getAction("Kilo.WorktreeSession.RowMenu") as? ActionGroup ?: DefaultActionGroup() private val list = ActiveList( KiloBundle.message("worktree.session.list.empty"), @@ -112,6 +125,10 @@ class WorktreeSessionEditorPanel( private var started = false private var stats: WorktreeStatsDto? = null private var pr: WorktreePrDto? = null + // Last known persisted visibility, and whether it is known at all: until the stored value arrives + // (or the user clicks) the list stays hidden and nothing is written. + private var pref: Boolean? = null + private var ready = false init { Disposer.register(parent, this) @@ -122,11 +139,11 @@ class WorktreeSessionEditorPanel( toolbar.component.isOpaque = false syncToolbar() list.installPopup(group) - splitter.firstComponent = list + // The list starts detached: a worktree opens with its sessions hidden until a stored choice or + // a second session says otherwise. splitter.secondComponent = manager.component addToTop(top()) addToCenter(splitter) - syncExpanded(KiloPluginSettings.getWorktreeSessionListExpanded()) bindModel() manager.onPresent = { key -> key?.let { list.select(it) } } manager.onListChanged = { @@ -145,6 +162,7 @@ class WorktreeSessionEditorPanel( } bindStatus() sync() + load(::restore) } override fun getBackground(): Color = activeListToolWindowBackground() @@ -249,18 +267,88 @@ class WorktreeSessionEditorPanel( private fun toolbarPanel(): JComponent { return object : JPanel(BorderLayout()) { override fun getBackground(): Color = activeListToolWindowBackground() + + // The padding and the divider colour both come from the theme, so they are re-read on + // Look-and-Feel changes instead of being captured once at construction. + override fun updateUI() { + super.updateUI() + border = toolbarPanelBorder() + } }.apply { - border = IdeBorderFactory.createBorder(SideBorder.RIGHT) + // The toggle is centred at its own height instead of tracking the strip, so its hover + // box keeps the strip's padding above and below it like a regular toolbar button. + add( + Stack.horizontal(gap = UiStyle.Gap.sm()) + .next(toggle.align(HAlign.LEFT, VAlign.CENTER)) + .next(JSeparator(SwingConstants.VERTICAL)), + BorderLayout.WEST, + ) add(toolbar.component, BorderLayout.CENTER) } } - @RequiresEdt - private fun toggleExpanded() { - syncExpanded(!expanded()) - KiloPluginSettings.setWorktreeSessionListExpanded(expanded()) + /** + * Standard horizontal-toolbar padding on the three free sides. The right edge stays flush so the + * divider still sits directly against the header content beside it. + * + * The theme insets arrive pre-scaled while [JBUI.Borders.empty] scales what it is handed, so the + * unscaled values are read back to avoid scaling twice on HiDPI. + */ + private fun toolbarPanelBorder(): Border { + val ins = (JBUI.CurrentTheme.Toolbar.horizontalToolbarInsets() as? JBInsets)?.unscaled + return JBUI.Borders.merge( + JBUI.Borders.empty(ins?.top ?: STRIP_PAD, ins?.left ?: STRIP_PAD, ins?.bottom ?: STRIP_PAD, 0), + IdeBorderFactory.createBorder(SideBorder.RIGHT), + true, + ) } + @RequiresEdt + private fun flip() { + syncExpanded(!expanded()) + ready = true + pref = expanded() + save(expanded()) + } + + /** + * Applies the stored visibility once the backend answers. A click that landed first already + * decided, so a late answer must not overwrite it. + */ + @RequiresEdt + private fun restore(value: Boolean?) { + if (ready) return + ready = true + pref = value + value?.let(::syncExpanded) + resolve() + } + + /** + * Shows the list the first time this worktree holds more than one session. Only that promotion is + * persisted, so a worktree the user never touched keeps writing nothing while it has one session. + */ + @RequiresEdt + private fun resolve() { + if (!ready || pref != null || count() < AUTO) return + syncExpanded(true) + pref = true + save(true) + } + + @RequiresEdt + private fun count(): Int { + val deleting = manager.deleting() + return controller.sessions().count { it.id !in deleting } + } + + @RequiresEdt + private fun syncToggle() = toggle.update( + expanded(), + count(), + attention(manager.activity(), manager.currentKey(), manager.deleting()), + ) + @RequiresEdt private fun expanded(): Boolean = splitter.firstComponent != null @@ -281,6 +369,7 @@ class WorktreeSessionEditorPanel( val changed = expanded() != value if (changed) splitter.firstComponent = if (value) list else null syncToolbar() + syncToggle() if (!changed) return splitter.revalidate() splitter.repaint() @@ -413,6 +502,8 @@ class WorktreeSessionEditorPanel( // the list hold that key until a refresh brings it in. val shown = if (pending) SessionHost.NEW else key list.update(rows, shown?.let { ActiveListSelection.Key(it) } ?: ActiveListSelection.Preserve) + resolve() + syncToggle() } @RequiresEdt @@ -481,20 +572,6 @@ class WorktreeSessionEditorPanel( manager.onListChanged = null } - private inner class ToggleAction : AnAction() { - override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT - - override fun update(e: AnActionEvent) { - val expanded = expanded() - e.presentation.text = KiloBundle.message(if (expanded) "worktree.session.list.collapse" else "worktree.session.list.expand") - e.presentation.icon = if (expanded) LAYOUT_FULL else LAYOUT_PARTIAL - } - - override fun actionPerformed(e: AnActionEvent) { - toggleExpanded() - } - } - private inner class NewAction : AnAction( KiloBundle.message("worktree.session.new.action"), null, @@ -552,8 +629,12 @@ class WorktreeSessionEditorPanel( private companion object { private val LOG = KiloLog.create(WorktreeSessionEditorPanel::class.java) private val TERMINAL_DIR = Key.create("kilo.worktree.terminal.dir") - val LAYOUT_PARTIAL: Icon = IconLoader.getIcon("/icons/layout-left-partial.svg", WorktreeSessionEditorPanel::class.java) - val LAYOUT_FULL: Icon = IconLoader.getIcon("/icons/layout-left-full.svg", WorktreeSessionEditorPanel::class.java) + + /** Sessions a worktree must hold before the list shows itself without being asked. */ + private const val AUTO = 2 + + /** Classic UI leaves the toolbar inset key unset; matches the platform's own fallback. */ + private const val STRIP_PAD = 2 } private inner class SessionRow( diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionListToggle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionListToggle.kt new file mode 100644 index 0000000000..d386260506 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionListToggle.kt @@ -0,0 +1,165 @@ +package ai.kilocode.client.agentManager.worktree + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.SessionActivityKind +import ai.kilocode.client.ui.FilledBadgeIcon +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.Stack +import com.intellij.openapi.util.IconLoader +import com.intellij.ui.components.JBLabel +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBUI +import java.awt.Cursor +import java.awt.Dimension +import java.awt.Graphics +import java.awt.Graphics2D +import java.awt.RenderingHints +import java.awt.event.ActionEvent +import java.awt.event.KeyEvent +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import javax.swing.AbstractAction +import javax.swing.Icon +import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.KeyStroke + +/** + * Shows/hides the worktree editor's session list. Sits left of the toolbar and, while the list is + * hidden, carries the session count in a neutral badge — or the activity icon of a background session + * that needs the user, so a pending question is visible with the list collapsed. + * + * Not a [ai.kilocode.client.ui.HoverIcon]: that hosts a single icon and pins icon-only buttons to + * 24x24, which would clip the trailing badge. The hover treatment is reproduced here instead. + */ +internal class WorktreeSessionListToggle( + private val onClick: () -> Unit, +) : JPanel(null) { + private val glyph = JBLabel(LAYOUT_PARTIAL) + private val badge = JBLabel() + private val row = Stack.horizontal(gap = UiStyle.Gap.sm()).next(glyph).next(badge) + private var state = State() + private var over = false + + init { + isOpaque = false + isFocusable = true + cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + badge.isVisible = false + label(KiloBundle.message("worktree.session.list.expand")) + add(row) + addMouseListener(object : MouseAdapter() { + override fun mouseEntered(event: MouseEvent) = hover(true) + override fun mouseExited(event: MouseEvent) = hover(false) + override fun mouseClicked(event: MouseEvent) { + if (isEnabled) onClick() + } + }) + // Keep the toggle reachable without a mouse, the way the toolbar action it replaced was. + val action = object : AbstractAction() { + override fun actionPerformed(e: ActionEvent) { + if (isEnabled) onClick() + } + } + getInputMap(JComponent.WHEN_FOCUSED).apply { + put(KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), ACTIVATE) + put(KeyStroke.getKeyStroke(KeyEvent.VK_SPACE, 0), ACTIVATE) + } + actionMap.put(ACTIVATE, action) + } + + override fun updateUI() { + super.updateUI() + border = JBUI.Borders.empty(JBUI.CurrentTheme.Toolbar.toolbarButtonInsets()) + } + + @RequiresEdt + fun update(expanded: Boolean, count: Int, kind: SessionActivityKind?) { + val next = State(expanded, count, kind) + if (next == state) return + state = next + glyph.icon = if (expanded) LAYOUT_FULL else LAYOUT_PARTIAL + val icon = badge(next) + badge.icon = icon + badge.isVisible = icon != null + label(KiloBundle.message(if (expanded) "worktree.session.list.collapse" else "worktree.session.list.expand")) + revalidate() + repaint() + } + + override fun getPreferredSize(): Dimension { + val ins = insets + val size = row.preferredSize + return Dimension(size.width + ins.left + ins.right, JBUI.scale(24)) + } + + override fun getMinimumSize(): Dimension = preferredSize + + override fun getMaximumSize(): Dimension = preferredSize + + override fun doLayout() { + val ins = insets + val w = maxOf(0, width - ins.left - ins.right) + val h = maxOf(0, height - ins.top - ins.bottom) + val size = row.preferredSize + val rowW = minOf(size.width, w) + val rowH = minOf(size.height, h) + row.setBounds(ins.left, ins.top + (h - rowH) / 2, rowW, rowH) + } + + override fun paintComponent(g: Graphics) { + if (over && isEnabled) paintHover(g) + super.paintComponent(g) + } + + /** + * Trailing badge while the list is hidden: a session that needs the user outranks the count, and a + * lone session needs no count at all. + */ + private fun badge(state: State): Icon? = when { + state.expanded -> null + state.kind != null -> state.kind.icon() + state.count >= COUNT -> FilledBadgeIcon(state.count.toString(), UiStyle.Badge.Secondary) + else -> null + } + + private fun label(text: String) { + toolTipText = text + // getAccessibleContext() lazily creates the context; the field itself is still null here. + getAccessibleContext().accessibleName = text + } + + private fun hover(value: Boolean) { + if (over == value) return + over = value + repaint() + } + + private fun paintHover(g: Graphics) { + val g2 = g.create() as Graphics2D + try { + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + g2.color = UiStyle.Colors.actionHoverBackground() + val arc = JBUI.scale(JBUI.getInt("Button.arc", 6)) + g2.fillRoundRect(0, 0, width, height, arc, arc) + } finally { + g2.dispose() + } + } + + private data class State( + val expanded: Boolean = false, + val count: Int = 0, + val kind: SessionActivityKind? = null, + ) + + private companion object { + const val ACTIVATE = "kilo.worktree.sessionList.activate" + + /** A single session is the norm, so the count only earns a badge from the second one on. */ + const val COUNT = 2 + private val OWNER = WorktreeSessionListToggle::class.java + val LAYOUT_PARTIAL: Icon = IconLoader.getIcon("/icons/layout-left-partial.svg", OWNER) + val LAYOUT_FULL: Icon = IconLoader.getIcon("/icons/layout-left-full.svg", OWNER) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionListVisibility.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionListVisibility.kt new file mode 100644 index 0000000000..3e1251203b --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionListVisibility.kt @@ -0,0 +1,29 @@ +package ai.kilocode.client.agentManager.worktree + +import ai.kilocode.client.util.edt +import ai.kilocode.log.KiloLog +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@Service(Service.Level.APP) +internal class WorktreeSessionListVisibility(private val cs: CoroutineScope) { + fun load(path: String, done: (Boolean?) -> Unit) { + cs.launch { + val value = service().sessionList(path) + edt { done(value) } + } + } + + fun save(path: String, visible: Boolean) { + cs.launch { + val ok = service().setSessionList(path, visible) + if (!ok) LOG.warn("worktree session list state write was not persisted: path=$path visible=$visible") + } + } + + private companion object { + val LOG = KiloLog.create(WorktreeSessionListVisibility::class.java) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloPluginSettings.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloPluginSettings.kt index 3e4e2bb36e..ac54c6597f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloPluginSettings.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloPluginSettings.kt @@ -7,7 +7,6 @@ object KiloPluginSettings { private const val AUTO_EDITOR_CONTEXT_KEY = "kilo.session.autoEditorContext" private const val SHOW_APPROVAL_REASON_KEY = "kilo.session.showApprovalReason" private const val PERMISSION_RULES_EXPANDED_KEY = "kilo.session.permissionRulesExpanded" - private const val WORKTREE_SESSION_LIST_EXPANDED_KEY = "kilo.worktree.sessionListExpanded" fun getAutoApprove(): Boolean = PropertiesComponent.getInstance().getBoolean(AUTO_APPROVE_KEY, false) @@ -48,14 +47,4 @@ object KiloPluginSettings { internal fun unsetPermissionRulesExpanded() { PropertiesComponent.getInstance().unsetValue(PERMISSION_RULES_EXPANDED_KEY) } - - fun getWorktreeSessionListExpanded(): Boolean = PropertiesComponent.getInstance().getBoolean(WORKTREE_SESSION_LIST_EXPANDED_KEY, true) - - fun setWorktreeSessionListExpanded(value: Boolean) { - PropertiesComponent.getInstance().setValue(WORKTREE_SESSION_LIST_EXPANDED_KEY, value.toString()) - } - - internal fun unsetWorktreeSessionListExpanded() { - PropertiesComponent.getInstance().unsetValue(WORKTREE_SESSION_LIST_EXPANDED_KEY) - } } 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 5270e31c18..170373395b 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 @@ -5,6 +5,7 @@ import ai.kilocode.rpc.dto.SessionActivityDto import ai.kilocode.rpc.dto.SessionActivityKindDto import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNull class WorktreeActivityTest { @Test @@ -46,6 +47,32 @@ class WorktreeActivityTest { assertEquals(SessionActivityKind.QUESTION, questionOverError["/repo/wt"]) } + @Test + fun `attention ranks waiting sessions and ignores running work`() { + val kinds = mapOf( + "ses_run" to SessionActivityKind.RUNNING, + "ses_error" to SessionActivityKind.ERROR, + "ses_question" to SessionActivityKind.QUESTION, + "ses_permission" to SessionActivityKind.PERMISSION, + ) + + assertEquals(SessionActivityKind.PERMISSION, attention(kinds)) + assertEquals(SessionActivityKind.QUESTION, attention(kinds, current = "ses_permission")) + assertNull(attention(mapOf("ses_run" to SessionActivityKind.RUNNING))) + assertNull(attention(emptyMap())) + } + + @Test + fun `attention skips the current and deleting sessions`() { + val kinds = mapOf( + "ses_open" to SessionActivityKind.QUESTION, + "ses_gone" to SessionActivityKind.PERMISSION, + ) + + assertNull(attention(kinds, current = "ses_open", deleting = setOf("ses_gone"))) + assertEquals(SessionActivityKind.QUESTION, attention(kinds, deleting = setOf("ses_gone"))) + } + @Test fun `normalizes trailing slashes`() { val result = aggregateWorktreeActivity(mapOf( 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 53381d7858..e81f84e401 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 @@ -15,7 +15,8 @@ import ai.kilocode.client.testing.TestCoroutines import ai.kilocode.client.testing.pumpEdt import ai.kilocode.client.testing.fire import ai.kilocode.client.plugin.KiloBundle -import ai.kilocode.client.plugin.KiloPluginSettings +import ai.kilocode.client.ui.FilledBadgeIcon +import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.list.ActiveList import ai.kilocode.client.ui.list.ActiveListItem import ai.kilocode.client.ui.list.activeListSectionTitle @@ -37,16 +38,22 @@ 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.JBLabel import com.intellij.ui.components.JBList import com.intellij.ui.components.JBScrollPane import com.intellij.util.ui.UIUtil import java.awt.Color +import com.intellij.util.ui.JBUI import java.awt.Container +import javax.swing.JPanel +import javax.swing.JSeparator +import javax.swing.SwingConstants import java.awt.Point import java.awt.event.ActionEvent import java.awt.event.InputEvent import java.awt.event.KeyEvent import java.awt.event.MouseEvent +import javax.swing.Icon import javax.swing.JButton import javax.swing.JComponent import javax.swing.KeyStroke @@ -61,29 +68,44 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() { private lateinit var controller: WorktreeSessionListController private lateinit var manager: FakeManager private lateinit var panel: WorktreeSessionEditorPanel + private val saves = mutableListOf() private val workspace = Workspace(DIR, kotlinx.coroutines.flow.MutableStateFlow(ai.kilocode.rpc.dto.KiloWorkspaceStateDto(ai.kilocode.rpc.dto.KiloWorkspaceStatusDto.READY)), {}, {}) override fun setUp() { super.setUp() - KiloPluginSettings.unsetWorktreeSessionListExpanded() coroutines = TestCoroutines() rpc = FakeSessionRpcApi() sessions = KiloSessionService(project, coroutines.scope, rpc) controller = WorktreeSessionListController(sessions, DIR, coroutines.scope) manager = FakeManager() - panel = edt { WorktreeSessionEditorPanel(testRootDisposable, manager, controller, workspace, confirm = { _, _, run -> run() }) } + // Most tests inspect the session list, so the shared panel starts from a stored "visible". + panel = view(stored = true) } override fun tearDown() { try { TestDialogManager.setTestDialog(TestDialog.DEFAULT) - KiloPluginSettings.unsetWorktreeSessionListExpanded() coroutines.close(::pump) } finally { super.tearDown() } } + private fun view( + stored: Boolean? = null, + load: ((Boolean?) -> Unit) -> Unit = { done -> done(stored) }, + ): WorktreeSessionEditorPanel = edt { + WorktreeSessionEditorPanel( + testRootDisposable, + manager, + controller, + workspace, + confirm = { _, _, run -> run() }, + load = load, + save = { saves += it }, + ) + } + fun `test panel builds splitter toolbar list and right component`() { edt { val splitter = UIUtil.findComponentOfType(panel, OnePixelSplitter::class.java)!! @@ -92,7 +114,8 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() { assertEquals(0.25f, splitter.proportion, 0.01f) assertNotNull(UIUtil.findComponentOfType(panel, WorktreePrHeaderView::class.java)) val buttons = components(panel).filterIsInstance().mapNotNull { it.presentation.text } - assertTrue(buttons.contains("Hide sessions")) + assertEquals("Hide sessions", toggle().toolTipText) + assertFalse(buttons.contains("Hide sessions")) assertTrue(buttons.contains("New session")) assertTrue(buttons.contains("Rename session")) assertTrue(buttons.contains("Delete session")) @@ -152,38 +175,230 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() { assertTrue(shown("Rename session")) assertTrue(shown("Delete session")) - toggle().click() + click(toggle()) assertNull(splitter.firstComponent) + assertEquals("Show sessions", toggle().toolTipText) assertTrue(shown("New session")) assertFalse(shown("Rename session")) assertFalse(shown("Delete session")) assertNotNull(UIUtil.findComponentOfType(panel, WorktreePrHeaderView::class.java)) - assertFalse(KiloPluginSettings.getWorktreeSessionListExpanded()) + assertEquals(listOf(false), saves) - toggle().click() + click(toggle()) assertSame(list, splitter.firstComponent) + assertEquals("Hide sessions", toggle().toolTipText) assertTrue(shown("Rename session")) assertTrue(shown("Delete session")) - assertTrue(KiloPluginSettings.getWorktreeSessionListExpanded()) + assertEquals(listOf(false, true), saves) } } - fun `test collapsed state persists for new panels`() { - edt { toggle().click() } - - val view = edt { WorktreeSessionEditorPanel(testRootDisposable, manager, controller, workspace, confirm = { _, _, run -> run() }) } + fun `test stored visibility decides whether the session list is shown`() { + val off = view(stored = false) + val on = view(stored = true) edt { - val splitter = UIUtil.findComponentOfType(view, OnePixelSplitter::class.java)!! - assertNull(splitter.firstComponent) - assertTrue(shown(view, "New session")) - assertFalse(shown(view, "Rename session")) - assertFalse(shown(view, "Delete session")) + assertNull(UIUtil.findComponentOfType(off, OnePixelSplitter::class.java)!!.firstComponent) + assertNotNull(UIUtil.findComponentOfType(on, OnePixelSplitter::class.java)!!.firstComponent) + assertFalse(shown(off, "Rename session")) + assertTrue(shown(on, "Rename session")) + assertTrue(saves.isEmpty()) } } + fun `test a worktree without a stored choice stays hidden for a single session`() { + val view = view() + rpc.listed += session("ses_1", 1.0) + edt { controller.reload() } + flush() + + edt { + assertNull(UIUtil.findComponentOfType(view, OnePixelSplitter::class.java)!!.firstComponent) + assertNull(badge(view)) + assertTrue(saves.isEmpty()) + } + } + + fun `test a second session shows the list once and stores that choice`() { + val view = view() + rpc.listed += session("ses_1", 1.0) + rpc.listed += session("ses_2", 2.0) + edt { controller.reload() } + flush() + + edt { + assertNotNull(UIUtil.findComponentOfType(view, OnePixelSplitter::class.java)!!.firstComponent) + assertEquals(listOf(true), saves) + } + + rpc.listed += session("ses_3", 3.0) + edt { controller.reload() } + flush() + + assertEquals(listOf(true), saves) + } + + fun `test a stored hidden list survives extra sessions`() { + val view = view(stored = false) + rpc.listed += session("ses_1", 1.0) + rpc.listed += session("ses_2", 2.0) + edt { controller.reload() } + flush() + + edt { + assertNull(UIUtil.findComponentOfType(view, OnePixelSplitter::class.java)!!.firstComponent) + assertTrue(saves.isEmpty()) + } + } + + fun `test a click before the stored value arrives wins`() { + var answer: ((Boolean?) -> Unit)? = null + val view = view(load = { done -> answer = done }) + + edt { click(toggle(view)) } + edt { answer!!(false) } + + edt { + assertNotNull(UIUtil.findComponentOfType(view, OnePixelSplitter::class.java)!!.firstComponent) + assertEquals(listOf(true), saves) + } + } + + fun `test sessions arriving before the stored answer never force the list open`() { + var answer: ((Boolean?) -> Unit)? = null + val view = view(load = { done -> answer = done }) + rpc.listed += session("ses_1", 1.0) + rpc.listed += session("ses_2", 2.0) + edt { controller.reload() } + flush() + + edt { assertNull(UIUtil.findComponentOfType(view, OnePixelSplitter::class.java)!!.firstComponent) } + assertTrue(saves.isEmpty()) + + edt { answer!!(false) } + + edt { + assertNull(UIUtil.findComponentOfType(view, OnePixelSplitter::class.java)!!.firstComponent) + assertTrue(saves.isEmpty()) + } + } + + fun `test an empty stored answer promotes a worktree that already holds two sessions`() { + var answer: ((Boolean?) -> Unit)? = null + val view = view(load = { done -> answer = done }) + rpc.listed += session("ses_1", 1.0) + rpc.listed += session("ses_2", 2.0) + edt { controller.reload() } + flush() + + edt { answer!!(null) } + + edt { + assertNotNull(UIUtil.findComponentOfType(view, OnePixelSplitter::class.java)!!.firstComponent) + assertEquals(listOf(true), saves) + } + } + + fun `test hidden toggle badges the session count from the second session on`() { + val view = view(stored = false) + rpc.listed += session("ses_1", 1.0) + edt { controller.reload() } + flush() + + assertNull(edt { badge(view) }) + + rpc.listed += session("ses_2", 2.0) + edt { controller.reload() } + flush() + + val icon = edt { badge(view) as FilledBadgeIcon } + assertEquals("2", icon.text) + assertSame(UiStyle.Badge.Secondary, icon.style) + } + + fun `test shown session list drops the count badge`() { + rpc.listed += session("ses_1", 1.0) + rpc.listed += session("ses_2", 2.0) + edt { controller.reload() } + flush() + + assertNull(edt { badge() }) + } + + fun `test hidden toggle surfaces a session waiting on the user instead of the count`() { + val view = view(stored = false) + manager.kinds = mapOf("ses_1" to SessionActivityKind.RUNNING, "ses_2" to SessionActivityKind.QUESTION) + rpc.listed += session("ses_1", 1.0) + rpc.listed += session("ses_2", 2.0) + edt { controller.reload() } + flush() + + assertSame(SessionActivityKind.QUESTION.icon(), edt { badge(view) }) + } + + fun `test toolbar strip pads three sides and keeps the divider flush`() { + val standard = JBUI.CurrentTheme.Toolbar.horizontalToolbarInsets()!! + + val ins = edt { strip().insets } + + assertEquals(standard.top, ins.top) + assertEquals(standard.left, ins.left) + assertEquals(standard.bottom, ins.bottom) + // Right carries the divider line only: padding there would push it off the header content. + assertEquals(1, ins.right) + } + + fun `test a vertical separator follows the toggle in the toolbar strip`() { + val kids = edt { row().components.toList() } + + assertEquals(2, kids.size) + assertTrue(SwingUtilities.isDescendingFrom(toggle(), kids.first())) + assertEquals(SwingConstants.VERTICAL, (kids.last() as JSeparator).orientation) + } + + fun `test toggle keeps its own height inside a taller toolbar strip`() { + edt { + val strip = strip() + strip.setSize(JBUI.scale(400), JBUI.scale(48)) + lay(strip) + } + + // Tracking the strip height would push the hover box against the strip's top and bottom. + assertEquals(JBUI.scale(24), edt { toggle().height }) + assertTrue(edt { toggle().y } > 0) + } + + fun `test attention badge returns after showing and hiding the list again`() { + val view = view(stored = false) + manager.kinds = mapOf("ses_1" to SessionActivityKind.RUNNING, "ses_2" to SessionActivityKind.QUESTION) + rpc.listed += session("ses_1", 1.0) + rpc.listed += session("ses_2", 2.0) + edt { controller.reload() } + flush() + + assertSame(SessionActivityKind.QUESTION.icon(), edt { badge(view) }) + + edt { click(toggle(view)) } + assertNull(edt { badge(view) }) + + edt { click(toggle(view)) } + + assertSame(SessionActivityKind.QUESTION.icon(), edt { badge(view) }) + } + + fun `test hidden toggle keeps the count while sessions only run`() { + val view = view(stored = false) + manager.kinds = mapOf("ses_1" to SessionActivityKind.RUNNING, "ses_2" to SessionActivityKind.RUNNING) + rpc.listed += session("ses_1", 1.0) + rpc.listed += session("ses_2", 2.0) + edt { controller.reload() } + flush() + + assertEquals("2", edt { (badge(view) as FilledBadgeIcon).text }) + } + fun `test editor kind delegates preferred focus to panel`() { edt { assertSame(UIUtil.findComponentOfType(panel, JBList::class.java), panel.preferredFocus()) @@ -550,9 +765,26 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() { return out } - private fun toggle(root: java.awt.Component = panel): ActionButton { - val actions = setOf("New session", "Rename session", "Delete session") - return components(root).filterIsInstance().first { it.presentation.text !in actions } + private fun toggle(root: java.awt.Component = panel): WorktreeSessionListToggle = + components(root).filterIsInstance().single() + + /** The row holding the toggle and its separator, left of the toolbar. */ + private fun row(): JPanel = edt { toggle().parent.parent as JPanel } + + /** The strip panel holding that row plus the action toolbar. */ + private fun strip(): JPanel = edt { row().parent as JPanel } + + /** Lays a detached subtree out top-down, since validate() is a no-op without a peer. */ + private fun lay(root: java.awt.Component) { + if (root !is Container) return + root.doLayout() + root.components.forEach(::lay) + } + + /** Trailing badge icon of the toggle, or null while it carries none. */ + private fun badge(root: java.awt.Component = panel): Icon? { + val label = components(toggle(root)).filterIsInstance().getOrNull(1) ?: return null + return if (label.isVisible) label.icon else null } private fun shown(text: String): Boolean = shown(panel, text) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionListToggleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionListToggleTest.kt new file mode 100644 index 0000000000..d55a3e3e39 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionListToggleTest.kt @@ -0,0 +1,101 @@ +package ai.kilocode.client.agentManager.worktree + +import ai.kilocode.client.session.SessionActivityKind +import ai.kilocode.client.ui.FilledBadgeIcon +import ai.kilocode.client.util.edtWait +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.UIUtil +import java.awt.Component +import java.awt.Container +import java.awt.Point +import java.awt.event.ActionEvent +import java.awt.event.InputEvent +import java.awt.event.KeyEvent +import java.awt.event.MouseEvent +import javax.swing.JComponent +import javax.swing.KeyStroke + +class WorktreeSessionListToggleTest : BasePlatformTestCase() { + private var clicks = 0 + private lateinit var toggle: WorktreeSessionListToggle + + override fun setUp() { + super.setUp() + clicks = 0 + toggle = edt { WorktreeSessionListToggle { clicks++ } } + } + + fun `test the badge follows count expansion and activity without rebuilding labels`() { + val glyph = labels()[0] + val badge = labels()[1] + + assertFalse(edt { badge.isVisible }) + assertEquals("Show sessions", edt { toggle.toolTipText }) + + edt { toggle.update(expanded = false, count = 1, kind = null) } + assertFalse(edt { badge.isVisible }) + + edt { toggle.update(expanded = false, count = 3, kind = null) } + assertEquals("3", edt { (badge.icon as FilledBadgeIcon).text }) + + edt { toggle.update(expanded = false, count = 3, kind = SessionActivityKind.PERMISSION) } + assertSame(SessionActivityKind.PERMISSION.icon(), edt { badge.icon }) + + edt { toggle.update(expanded = true, count = 3, kind = SessionActivityKind.PERMISSION) } + assertFalse(edt { badge.isVisible }) + assertEquals("Hide sessions", edt { toggle.toolTipText }) + + // The retained tree is mutated in place, never rebuilt. + assertSame(glyph, labels()[0]) + assertSame(badge, labels()[1]) + assertEquals(2, labels().size) + } + + fun `test the glyph swaps between the collapsed and expanded icons`() { + val glyph = labels()[0] + val collapsed = edt { glyph.icon } + + edt { toggle.update(expanded = true, count = 0, kind = null) } + val expanded = edt { glyph.icon } + + assertNotSame(collapsed, expanded) + + edt { toggle.update(expanded = false, count = 0, kind = null) } + assertSame(collapsed, edt { glyph.icon }) + } + + fun `test mouse and keyboard both activate the toggle`() { + edt { + toggle.size = toggle.preferredSize + val point = Point(toggle.width / 2, toggle.height / 2) + toggle.dispatchEvent( + MouseEvent(toggle, MouseEvent.MOUSE_CLICKED, System.currentTimeMillis(), InputEvent.BUTTON1_DOWN_MASK, point.x, point.y, 1, false, MouseEvent.BUTTON1), + ) + } + UIUtil.dispatchAllInvocationEvents() + + assertEquals(1, clicks) + + edt { + val key = toggle.getInputMap(JComponent.WHEN_FOCUSED).get(KeyStroke.getKeyStroke(KeyEvent.VK_SPACE, 0)) + toggle.actionMap.get(key).actionPerformed(ActionEvent(toggle, ActionEvent.ACTION_PERFORMED, "")) + } + + assertEquals(2, clicks) + } + + private fun labels(): List = edt { components(toggle).filterIsInstance() } + + 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 edt(block: () -> T): T = edtWait(block) +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionListVisibilityTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionListVisibilityTest.kt new file mode 100644 index 0000000000..c116017df2 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionListVisibilityTest.kt @@ -0,0 +1,98 @@ +package ai.kilocode.client.agentManager.worktree + +import ai.kilocode.client.testing.FakeWorktreeRpcApi +import ai.kilocode.client.testing.TestCoroutines +import ai.kilocode.client.testing.pumpEdt +import com.intellij.openapi.application.ApplicationManager +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.testFramework.replaceService + +@Suppress("UnstableApiUsage") +class WorktreeSessionListVisibilityTest : BasePlatformTestCase() { + private lateinit var coroutines: TestCoroutines + private lateinit var rpc: FakeWorktreeRpcApi + private lateinit var visibility: WorktreeSessionListVisibility + + override fun setUp() { + super.setUp() + coroutines = TestCoroutines() + rpc = FakeWorktreeRpcApi() + ApplicationManager.getApplication() + .replaceService(KiloWorktreeService::class.java, KiloWorktreeService(coroutines.scope, rpc), testRootDisposable) + visibility = WorktreeSessionListVisibility(coroutines.scope) + } + + override fun tearDown() { + try { + coroutines.close(::pump) + } finally { + super.tearDown() + } + } + + fun `test load answers with the stored value on the edt`() { + rpc.sessionLists[DIR] = true + val values = mutableListOf() + val threads = mutableListOf() + + visibility.load(DIR) { value -> + values += value + threads += ApplicationManager.getApplication().isDispatchThread + } + drain() + + assertEquals(listOf(DIR), rpc.sessionListReads.toList()) + assertEquals(listOf(true), values) + assertEquals(listOf(true), threads) + } + + fun `test load answers with nothing for a worktree without a stored choice`() { + val values = mutableListOf() + + visibility.load(DIR) { values += it } + drain() + + assertEquals(listOf(null), values) + } + + fun `test load degrades to nothing when the backend fails`() { + rpc.sessionLists[DIR] = true + rpc.sessionListThrows = RuntimeException("backend down") + val values = mutableListOf() + + visibility.load(DIR) { values += it } + drain() + + assertEquals(listOf(null), values) + } + + fun `test save records the visibility a later load reads back`() { + visibility.save(DIR, false) + drain() + + assertEquals(listOf(DIR to false), rpc.sessionListWrites.toList()) + + val values = mutableListOf() + visibility.load(DIR) { values += it } + drain() + + assertEquals(listOf(false), values) + } + + fun `test save survives a failing backend`() { + rpc.sessionListThrows = RuntimeException("backend down") + + visibility.save(DIR, true) + drain() + + assertEquals(listOf(DIR to true), rpc.sessionListWrites.toList()) + } + + private fun drain() = coroutines.drain(::pump) + + private fun pump() = pumpEdt() + + private companion object { + const val DIR = "/repo/.kilo/worktrees/feature-x" + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorktreeRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorktreeRpcApi.kt index 31a3fbf980..54d9aa68f5 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorktreeRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorktreeRpcApi.kt @@ -15,6 +15,7 @@ import ai.kilocode.rpc.dto.WorktreePrListDto import ai.kilocode.rpc.dto.WorktreeStatsListDto import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.asFlow +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CopyOnWriteArrayList /** @@ -41,6 +42,12 @@ class FakeWorktreeRpcApi : KiloWorktreeRpcApi { val adopts = CopyOnWriteArrayList>() val reorders = CopyOnWriteArrayList>() var reorderResult = true + /** Stored session-list visibility per worktree path, plus the calls that touched it. */ + val sessionLists = ConcurrentHashMap() + val sessionListReads = CopyOnWriteArrayList() + val sessionListWrites = CopyOnWriteArrayList>() + /** When set, both session-list calls throw it instead of answering. */ + var sessionListThrows: Exception? = null val opens = CopyOnWriteArrayList() val ghCalls = CopyOnWriteArrayList() var beforeCreate: suspend () -> Unit = {} @@ -153,4 +160,19 @@ class FakeWorktreeRpcApi : KiloWorktreeRpcApi { reorders.add(paths) return reorderResult } + + override suspend fun sessionList(directory: String): Boolean? { + assertNotEdt("sessionList") + sessionListReads.add(directory) + sessionListThrows?.let { throw it } + return sessionLists[directory] + } + + override suspend fun setSessionList(directory: String, visible: Boolean): Boolean { + assertNotEdt("setSessionList") + sessionListWrites.add(directory to visible) + sessionListThrows?.let { throw it } + sessionLists[directory] = visible + return true + } } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorktreeRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorktreeRpcApi.kt index 08b0699b6e..bf2d90229f 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorktreeRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorktreeRpcApi.kt @@ -87,4 +87,13 @@ interface KiloWorktreeRpcApi : RemoteApi { * `git worktree list` (unknown paths dropped, missing ones appended). Returns true when written. */ suspend fun reorder(directory: String, paths: List): Boolean + + /** + * Returns the persisted session-list visibility for [directory], or null when the user has not + * chosen a value yet. + */ + suspend fun sessionList(directory: String): Boolean? + + /** Records the session-list visibility for [directory]. Returns true when written. */ + suspend fun setSessionList(directory: String, visible: Boolean): Boolean } From 9f6af44149acbf5012c0537e7fdc2434bad49cbd Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 27 Aug 2026 17:19:21 -0400 Subject: [PATCH 4/4] fix(jetbrains): reword German worktree empty-state hint 'Arbeiten Sie frei' reads as a harsh historical imperative in German. Use a neutral phrasing that conveys the actual intent: it is safe to experiment because the main checkout stays untouched. --- .../src/main/resources/messages/KiloBundle_de.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties index e7127d8cda..fba94cbc87 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties @@ -19,8 +19,8 @@ session.connection.warning.config=Konfigurationswarnungen session.empty.welcome=Kilo Code ist ein KI-Coding-Assistent. Bitten Sie ihn, Funktionen zu erstellen, Fehler zu beheben oder Ihre Codebasis zu erklären. session.empty.branch=Sie arbeiten direkt auf {0}. Starten Sie eine Aufgabe oder {1}, um Änderungen isoliert zu halten. session.empty.branch.link=führen Sie sie in einem Worktree aus -session.empty.worktree=Sie sind in einem isolierten Worktree auf {0}. Arbeiten Sie frei — Ihr Haupt-Checkout bleibt unberührt. -session.empty.worktree.unknown=Sie sind in einem isolierten Worktree. Arbeiten Sie frei — Ihr Haupt-Checkout bleibt unberührt. +session.empty.worktree=Sie sind in einem isolierten Worktree auf {0}. Sie können hier gefahrlos experimentieren — Ihr Haupt-Checkout bleibt unberührt. +session.empty.worktree.unknown=Sie sind in einem isolierten Worktree. Sie können hier gefahrlos experimentieren — Ihr Haupt-Checkout bleibt unberührt. session.empty.loading=Wird geladen… session.empty.recent=ZULETZT session.showHistory=Verlauf anzeigen