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/.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/.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/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/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/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/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 f73dca20d1..c3e6f32083 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -42,6 +42,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... @@ -472,8 +476,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/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..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 @@ -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}. 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 @@ -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/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) + } +} 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/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) 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 }