diff --git a/.changeset/jetbrains-worktree-adopt-session-name.md b/.changeset/jetbrains-worktree-adopt-session-name.md new file mode 100644 index 00000000000..f936a159cf5 --- /dev/null +++ b/.changeset/jetbrains-worktree-adopt-session-name.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": minor +--- + +Name Agent Manager worktrees from the first session's title. When a worktree still uses its default branch name, the title the agent generates for its first session becomes the worktree name — updating both the worktree list and the editor live as the name arrives. Placeholder session names are ignored so only the real agent title is adopted, and worktrees you have renamed yourself are left untouched. The worktree session list now also shows agent-generated session titles as they stream in. 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 775bafcdfd0..d6d2e7b863d 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 @@ -120,6 +120,33 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { } } + override suspend fun adopt(directory: String, path: String, name: String): RenameWorktreeResultDto = + withContext(Dispatchers.IO) { + val title = name.trim() + if (title.isEmpty()) return@withContext RenameWorktreeResultDto() + val base = Path.of(directory).normalize() + val res = runGit(base, "worktree", "list", "--porcelain") + if (!res.ok) return@withContext RenameWorktreeResultDto(error = res.stderr.ifBlank { "git worktree list failed" }) + val items = managedWorktrees(parseWorktreeList(res.stdout)) + val store = worktreeNameStore(items) + ?: return@withContext RenameWorktreeResultDto(error = "Main worktree not found") + val target = items.firstOrNull { samePath(it.path, path) && !it.main } + ?: return@withContext RenameWorktreeResultDto(error = "Worktree not found") + return@withContext try { + val names = readWorktreeNames(store).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. + if (!names[target.path].isNullOrBlank()) return@withContext RenameWorktreeResultDto() + names[target.path] = title + writeWorktreeNames(store, names) + LOG.info("worktree name adopted: path=$path name=$title") + RenameWorktreeResultDto(worktree = target.copy(name = title)) + } catch (e: Exception) { + LOG.warn("worktree adopt failed: path=$path message=${e.message}", e) + RenameWorktreeResultDto(error = e.message ?: "worktree adopt failed") + } + } + private data class GitResult(val exit: Int, val stdout: String, val stderr: String) { val ok get() = exit == 0 } 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 2e8d22f0b01..f4a0ef4c714 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 @@ -200,6 +200,48 @@ class KiloWorktreeRpcApiImplTest { assertEquals(mapOf(created.path to "Feature Label"), readWorktreeNames(repo.resolve(".kilo").resolve("worktree-names.json"))) } + @Test + fun `adopt names a default worktree and list overlays the adopted name`() = runBlocking { + initRepo() + val created = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("feature/x")).worktree) + + val adopted = api.adopt(repo.toString(), created.path, "Fix login bug") + + assertNull(adopted.error) + assertEquals("Fix login bug", assertNotNull(adopted.worktree).name) + val listed = api.list(repo.toString()).worktrees.single { it.path == created.path } + assertEquals("Fix login bug", listed.name) + assertEquals(mapOf(created.path to "Fix login bug"), readWorktreeNames(repo.resolve(".kilo").resolve("worktree-names.json"))) + } + + @Test + fun `adopt leaves a worktree that already has a custom name untouched`() = runBlocking { + initRepo() + val created = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("feature/x")).worktree) + assertNotNull(api.rename(repo.toString(), created.path, "Chosen Name").worktree) + + val adopted = api.adopt(repo.toString(), created.path, "Agent Title") + + assertNull(adopted.error, "a skipped adopt is a no-op, not a failure") + assertNull(adopted.worktree, "a worktree with a custom name should not be adopted") + val listed = api.list(repo.toString()).worktrees.single { it.path == created.path } + assertEquals("Chosen Name", listed.name, "the user's name must be preserved") + } + + @Test + fun `adopt works when addressed from within the worktree directory`() = runBlocking { + initRepo() + val created = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("feature/x")).worktree) + + // The session editor only knows the worktree path, so it passes that as both directory and path. + val adopted = api.adopt(created.path, created.path, "Fix login bug") + + assertNull(adopted.error) + assertEquals("Fix login bug", assertNotNull(adopted.worktree).name) + val listed = api.list(repo.toString()).worktrees.single { it.path == created.path } + assertEquals("Fix login bug", listed.name) + } + @Test fun `remove reports failure when git cannot remove the worktree`() = runBlocking { initRepo() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt index 48a496d6b88..3612948fee4 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt @@ -4,6 +4,7 @@ import ai.kilocode.client.KiloNotifications import ai.kilocode.client.agentManager.worktree.ConfigureWorktreeDialog import ai.kilocode.client.agentManager.worktree.WorktreeController import ai.kilocode.client.agentManager.worktree.WorktreeIcons +import ai.kilocode.client.agentManager.worktree.WorktreeNameCache import ai.kilocode.client.agentManager.worktree.WorktreeSessionEditorKind import ai.kilocode.client.agentManager.worktree.ensureWorktreeSessionEditorKind import ai.kilocode.client.agentManager.worktree.worktreeSessionParams @@ -89,6 +90,8 @@ class AgentManagerPanel( } controller.onCreateFailure = { err -> notifyCreateFailed(err) } controller.onRemoveSuccess = { item -> close(item) } + // Reflect names adopted or renamed in a worktree session editor tab in the list live. + service().addListener(this) { path, name -> controller.applyName(path, name) } ActionManager.getInstance().getAction("RenameElement")?.shortcutSet?.let { set -> edit.registerCustomShortcutSet(set, list, this) } 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 69faf565f31..d27dbd08de7 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 @@ -66,4 +66,11 @@ class KiloWorktreeService internal constructor( LOG.warn("worktree rename failed for $path", e) RenameWorktreeResultDto(error = e.message ?: "worktree rename failed") } + + suspend fun adopt(directory: String, path: String, name: String): RenameWorktreeResultDto = try { + call { adopt(directory, path, name) } + } catch (e: Exception) { + LOG.warn("worktree adopt failed for $path", e) + RenameWorktreeResultDto(error = e.message ?: "worktree adopt failed") + } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeController.kt index 3a91830390e..f32e59eb421 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeController.kt @@ -177,6 +177,19 @@ class WorktreeController( } } + /** + * Applies a name recorded elsewhere (e.g. adopted from a session title in an editor tab) to the + * matching row, so the worktree list reflects it live. No-ops when the path is not in this list + * or the name already matches, which also makes it safe against the cache echoing our own writes. + */ + fun applyName(path: String, name: String?) { + if (name.isNullOrBlank()) return + val idx = (0 until model.size).firstOrNull { model.getElementAt(it).path == path } ?: return + val row = model.getElementAt(idx) + if (row.name == name) return + model.setElementAt(row.copy(name = name), idx) + } + private fun refresh(dto: WorktreeDto) { val idx = model.getElementIndex(dto) if (idx >= 0) model.setElementAt(dto, idx) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeNameCache.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeNameCache.kt index 7839c06e59c..c5b1fcc8ed7 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeNameCache.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeNameCache.kt @@ -1,31 +1,56 @@ package ai.kilocode.client.agentManager.worktree import ai.kilocode.rpc.dto.WorktreeDto +import com.intellij.openapi.Disposable import com.intellij.openapi.components.Service +import com.intellij.openapi.util.Disposer +import com.intellij.util.concurrency.annotations.RequiresEdt +/** + * App-level map of worktree path to display name, shared by the tool-window worktree list and the + * worktree session editor tabs. EDT-only. Single-path changes notify listeners so a name adopted or + * renamed in one surface (e.g. an editor tab) can update the other (the worktree list) live. + */ @Service(Service.Level.APP) class WorktreeNameCache { private val names = linkedMapOf() + private val listeners = mutableListOf<(String, String?) -> Unit>() fun get(path: String): String? = names[path] + @RequiresEdt fun put(path: String, name: String) { + if (names[path] == name) return names[path] = name + fire(path, name) } - fun put(item: WorktreeDto) { - names[item.path] = item.name - } + @RequiresEdt + fun put(item: WorktreeDto) = put(item.path, item.name) + @RequiresEdt fun remove(path: String) { - names.remove(path) + if (names.remove(path) != null) fire(path, null) } fun clear() { names.clear() } + /** + * Bulk sync from a worktree list reload. Does not notify: it mirrors the list model that + * triggered it, so notifying would only echo back into that same list. + */ fun putAll(items: List) { - items.forEach(::put) + items.forEach { names[it.path] = it.name } + } + + fun addListener(parent: Disposable, listener: (path: String, name: String?) -> Unit) { + listeners.add(listener) + Disposer.register(parent) { listeners.remove(listener) } + } + + private fun fire(path: String, name: String?) { + listeners.toList().forEach { it(path, name) } } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorManager.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorManager.kt index e9de46501bb..b64d6341150 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorManager.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorManager.kt @@ -15,7 +15,10 @@ import ai.kilocode.client.session.history.HistoryTime import ai.kilocode.client.session.history.LocalHistoryItem import ai.kilocode.client.util.UiTimerSource import ai.kilocode.client.util.UiTimers +import ai.kilocode.client.vfs.KiloVfsManager +import ai.kilocode.rpc.dto.RenameWorktreeResultDto import ai.kilocode.rpc.dto.SessionDto +import ai.kilocode.rpc.dto.WorktreeDto import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.ModalityState @@ -24,6 +27,8 @@ import com.intellij.openapi.project.Project import com.intellij.openapi.util.Disposer import com.intellij.openapi.wm.IdeFocusManager import com.intellij.util.concurrency.annotations.RequiresEdt +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch import java.awt.BorderLayout import javax.swing.JComponent import javax.swing.JPanel @@ -46,11 +51,23 @@ open class WorktreeSessionEditorManager( }, ModalityState.defaultModalityState()) }, private val notify: (String, String?) -> Unit = { title, content -> KiloNotifications.error(project, title, content) }, + private val cs: CoroutineScope = service().scope(), + private val adopt: suspend (String, String, String) -> RenameWorktreeResultDto = { dir, path, name -> + service().adopt(dir, path, name) + }, + private val onAdopted: (WorktreeDto) -> Unit = { updated -> + service().put(updated) + if (!project.isDisposed) { + project.service().updatePresentation(WorktreeSessionEditorKind.ID, worktreeSessionParams(updated)) + } + }, ) : SessionHost(project, worktree, create, resolve, status, timers, request) { private val right = JPanel(BorderLayout()) private val deleting = linkedSetOf() private var last: String? = null private var pending = false + private var adopted = false + private var adopting = false var onPresent: ((String?) -> Unit)? = null var onListChanged: (() -> Unit)? = null @@ -102,11 +119,58 @@ open class WorktreeSessionEditorManager( val id = currentUi()?.id if (last == null && id != null) { last = id - list.reload { onListChanged?.invoke() } + list.reload { onListChanged?.invoke(); maybeAdoptName() } return } last = id onListChanged?.invoke() + maybeAdoptName() + } + + /** + * When the first session in this worktree receives an agent-generated title, hand that title to + * the worktree so its header stops showing the default branch name. The backend only applies it + * while the worktree is still default, so a name the user chose is never overwritten. Runs at + * most once per manager — a resolved adopt (applied or skipped) latches [adopted]. + */ + @RequiresEdt + private fun maybeAdoptName() { + if (adopted || adopting) return + val title = adoptTitle() ?: return + adopting = true + val path = worktree.directory + cs.launch { + val result = adopt(path, path, title) + edt { + adopting = false + val updated = result.worktree + when { + updated != null -> { + adopted = true + onAdopted(updated) + } + result.error == null -> adopted = true // already has a custom name; stop trying + } + } + } + } + + /** + * Title of the earliest-created session that already has an agent-generated name, preferring + * live open sessions over the last listed snapshot. Sessions start with a "New session - " + * placeholder ([isDefaultSessionTitle]); those are skipped so the worktree adopts the real title + * the agent produces, not the placeholder. + */ + @RequiresEdt + private fun adoptTitle(): String? { + val live = titles() + return (0 until list.model.size) + .map { list.model.getElementAt(it) } + .sortedBy { it.time.created } + .firstNotNullOfOrNull { s -> + (live[s.id]?.takeIf { it.isNotBlank() } ?: s.title.takeIf { it.isNotBlank() }) + ?.takeUnless(::isDefaultSessionTitle) + } } @RequiresEdt @@ -157,7 +221,7 @@ open class WorktreeSessionEditorManager( @RequiresEdt override fun onSessionsChanged() { - list.reload { onListChanged?.invoke() } + list.reload { onListChanged?.invoke(); maybeAdoptName() } } @RequiresEdt @@ -188,3 +252,15 @@ open class WorktreeSessionEditorManager( ?: KiloBundle.message("worktree.session.untitled") } } + +private fun edt(block: () -> Unit) { + val app = ApplicationManager.getApplication() + if (app.isDispatchThread) block() else app.invokeLater(block) +} + +// Mirrors the CLI's Session.isDefaultTitle (packages/opencode/src/session/session.ts): a session +// keeps a "New session - " / "Child session - " placeholder until the agent names it. +private val DEFAULT_SESSION_TITLE = + Regex("^(New session - |Child session - )\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$") + +internal fun isDefaultSessionTitle(title: String): Boolean = DEFAULT_SESSION_TITLE.matches(title) 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 0d7430bf2fc..60bafe6c161 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 @@ -202,10 +202,11 @@ class WorktreeSessionEditorPanel( val key = manager.currentKey() val pending = manager.hasPendingNew() val kinds = manager.activity() + val titles = manager.titles() val deleting = manager.deleting() if (pending || key == SessionHost.NEW) rows += NewRow rows += HistoryTime.sorted((0 until controller.model.size).map { LocalHistoryItem(controller.model.getElementAt(it)) }) - .map { SessionRow(it.session, kinds[it.id], deleting = it.id in deleting) } + .map { SessionRow(it.session, kinds[it.id], deleting = it.id in deleting, live = titles[it.id]) } list.update(rows, ActiveListSelection.PreserveNoScroll) select(if (pending) SessionHost.NEW else key) } @@ -318,11 +319,19 @@ class WorktreeSessionEditorPanel( val session: SessionDto, val kind: SessionActivityKind?, override val deleting: Boolean = false, + // Live title of the open session, if any; reflects the agent-generated name as it streams in + // before the listed snapshot catches up. + private val live: String? = null, ) : ActiveListItem { private val item = LocalHistoryItem(session) override val key: String get() = session.id - override val title: String get() = session.title.takeIf { it.isNotBlank() } - ?: KiloBundle.message("worktree.session.untitled") + override val title: String get() { + val name = live?.takeIf { it.isNotBlank() } ?: session.title + if (name.isBlank()) return KiloBundle.message("worktree.session.untitled") + // Show the placeholder as a friendly "New session" until the agent names the session. + if (isDefaultSessionTitle(name)) return KiloBundle.message("worktree.session.new") + return name + } override val tooltip: String get() = title override val badges: List get() = listOfNotNull(kind?.let { ActiveListBadge(it.label(), it.style()) }) override val section: String get() = HistoryTime.title(HistoryTime.section(item)) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeControllerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeControllerTest.kt index 0a1ccf16ea9..bbd98a3135a 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeControllerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeControllerTest.kt @@ -302,6 +302,51 @@ class WorktreeControllerTest : BasePlatformTestCase() { assertTrue(worktreeDeletable(child, pending = false)) } + fun `test applyName updates the matching row so an adopted name shows live`() { + val item = WorktreeDto("/repo/.kilo/worktrees/feature-x", "feature-x", "feature/x", "/repo/.kilo/worktrees/feature-x") + rpc.listed += item + val controller = controller() + controller.reload() + flush() + + ApplicationManager.getApplication().invokeAndWait { controller.applyName(item.path, "Repository overview request") } + + assertEquals("Repository overview request", controller.model.getElementAt(0).name) + } + + fun `test applyName ignores unknown paths, identical names, and blanks`() { + val item = WorktreeDto("/repo/.kilo/worktrees/feature-x", "feature-x", "feature/x", "/repo/.kilo/worktrees/feature-x") + rpc.listed += item + val controller = controller() + controller.reload() + flush() + val before = controller.model.getElementAt(0) + + ApplicationManager.getApplication().invokeAndWait { + controller.applyName("/repo/.kilo/worktrees/other", "Ignored") + controller.applyName(item.path, "feature-x") + controller.applyName(item.path, null) + } + + assertSame(before, controller.model.getElementAt(0)) + } + + fun `test cache notifies on single put and remove but not on bulk sync`() { + val cache = cache() + val events = mutableListOf>() + cache.addListener(testRootDisposable) { path, name -> events += path to name } + + ApplicationManager.getApplication().invokeAndWait { + cache.put("/wt", "Name") + cache.put("/wt", "Name") + cache.putAll(listOf(WorktreeDto("/wt2", "Two", "b", "/wt2"))) + cache.remove("/wt") + cache.remove("/wt") + } + + assertEquals(listOf("/wt" to "Name", "/wt" to null), events) + } + private fun controller() = WorktreeController(service, "/test", coroutines.scope) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorManagerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorManagerTest.kt index a90ce7e2d29..70a6fce3cf6 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorManagerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorManagerTest.kt @@ -19,8 +19,10 @@ import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import ai.kilocode.rpc.dto.RenameWorktreeResultDto import ai.kilocode.rpc.dto.SessionDto import ai.kilocode.rpc.dto.SessionTimeDto +import ai.kilocode.rpc.dto.WorktreeDto import com.intellij.openapi.ui.TestDialog import com.intellij.openapi.ui.TestDialogManager import com.intellij.openapi.util.Disposer @@ -230,7 +232,65 @@ class WorktreeSessionEditorManagerTest : BasePlatformTestCase() { assertEquals(listOf("Failed to rename session \"Renamed Session\"" to "rename unavailable"), notified) } - private fun manager(controller: WorktreeSessionListController = WorktreeSessionListController(sessions, DIR, coroutines.scope)): WorktreeSessionEditorManager { + fun `test placeholder session title is not adopted as the worktree name`() { + rpc.listed += session("ses_1", updated = 1.0).copy(title = "New session - 2026-07-30T19:01:40.945Z") + val calls = mutableListOf() + val manager = manager( + adopt = { _, _, name -> + calls += name + RenameWorktreeResultDto(worktree = WorktreeDto(DIR, name, "feature-x", DIR)) + }, + ) + + edt { manager.start() } + flush() + + assertTrue("the CLI placeholder title must not be adopted", calls.isEmpty()) + } + + fun `test first named session adopts the worktree name`() { + rpc.listed += session("ses_1", updated = 1.0).copy(title = "Fix login bug") + val calls = mutableListOf>() + val adoptedNames = mutableListOf() + val manager = manager( + adopt = { dir, path, name -> + calls += Triple(dir, path, name) + RenameWorktreeResultDto(worktree = WorktreeDto(path, name, "feature-x", path)) + }, + onAdopted = { adoptedNames += it.name }, + ) + + edt { manager.start() } + waitUntil { calls.isNotEmpty() } + + assertEquals(listOf(Triple(DIR, DIR, "Fix login bug")), calls) + assertEquals(listOf("Fix login bug"), adoptedNames) + } + + fun `test skipped adoption keeps the default name and stops retrying`() { + rpc.listed += session("ses_1", updated = 1.0).copy(title = "Fix login bug") + rpc.session = session("ses_2", updated = 5.0).copy(title = "Another task") + val calls = mutableListOf() + val adoptedNames = mutableListOf() + val manager = manager( + adopt = { _, _, name -> calls += name; RenameWorktreeResultDto() }, + onAdopted = { adoptedNames += it.name }, + ) + + edt { manager.start() } + waitUntil { calls.isNotEmpty() } + edt { manager.newSession() } + flush() + + assertEquals(listOf("Fix login bug"), calls) + assertTrue(adoptedNames.isEmpty()) + } + + private fun manager( + controller: WorktreeSessionListController = WorktreeSessionListController(sessions, DIR, coroutines.scope), + adopt: suspend (String, String, String) -> RenameWorktreeResultDto = { _, _, _ -> RenameWorktreeResultDto() }, + onAdopted: (WorktreeDto) -> Unit = {}, + ): WorktreeSessionEditorManager { return WorktreeSessionEditorManager( parent = testRootDisposable, project = project, @@ -264,6 +324,9 @@ class WorktreeSessionEditorManagerTest : BasePlatformTestCase() { timers = timers, request = { requested += it }, notify = { title, content -> notified += title to content }, + cs = coroutines.scope, + adopt = adopt, + onAdopted = onAdopted, ) } 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 f8d8bcfffec..aa09cf55d39 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,6 +15,7 @@ import ai.kilocode.client.ui.list.ActiveListBadge import ai.kilocode.client.ui.list.ActiveListItem import ai.kilocode.client.ui.list.activeListSectionTitle import ai.kilocode.client.ui.list.activeListToolWindowBackground +import ai.kilocode.rpc.dto.RenameWorktreeResultDto import ai.kilocode.rpc.dto.SessionDto import ai.kilocode.rpc.dto.SessionTimeDto import com.intellij.openapi.actionSystem.DataKey @@ -148,6 +149,21 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() { assertEquals(HistoryTime.title(HistoryTime.section(LocalHistoryItem(session))), row.section) } + fun `test session row shows the live agent title over the listed placeholder`() { + rpc.listed += session("ses_1", nowSeconds()).copy(title = "New session - 2026-07-30T19:01:40.945Z") + edt { controller.reload() } + flush() + + // The listed snapshot is still the CLI placeholder, shown as a friendly "New session". + assertEquals("New session", row("ses_1").title) + + // The agent names the open session; the live title wins immediately on the next sync. + manager.live = mapOf("ses_1" to "Repository overview request") + edt { manager.onListChanged?.invoke() } + + assertEquals("Repository overview request", row("ses_1").title) + } + fun `test deleting row shows deleting state`() { manager.kinds = mapOf("ses_1" to SessionActivityKind.RUNNING) manager.deletingIds += "ses_1" @@ -423,10 +439,13 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() { controller, create = { _, _, _, _, _ -> error("unused") }, request = {}, + cs = coroutines.scope, + adopt = { _, _, _ -> RenameWorktreeResultDto() }, ) { var newCount = 0 var pending = false var kinds = emptyMap() + var live = emptyMap() val deletingIds = mutableSetOf() val refs = mutableListOf() val focuses = mutableListOf() @@ -437,6 +456,8 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() { override fun activity(): Map = kinds + override fun titles(): Map = live + override fun deleting(): Set = deletingIds override fun newSession() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionTitleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionTitleTest.kt new file mode 100644 index 00000000000..5f2295bc962 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionTitleTest.kt @@ -0,0 +1,22 @@ +package ai.kilocode.client.agentManager.worktree + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class WorktreeSessionTitleTest { + + @Test + fun `default placeholder titles are detected`() { + assertTrue(isDefaultSessionTitle("New session - 2026-07-30T19:01:40.945Z")) + assertTrue(isDefaultSessionTitle("Child session - 2026-07-30T19:01:40.945Z")) + } + + @Test + fun `agent-generated and user titles are not default`() { + assertFalse(isDefaultSessionTitle("Repository overview request")) + assertFalse(isDefaultSessionTitle("")) + assertFalse(isDefaultSessionTitle("New session - not a timestamp")) + assertFalse(isDefaultSessionTitle("New session")) + } +} 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 f06f7b33226..8d93748827e 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 @@ -22,9 +22,13 @@ class FakeWorktreeRpcApi : KiloWorktreeRpcApi { val removes = CopyOnWriteArrayList>() val removeForces = CopyOnWriteArrayList() val renames = CopyOnWriteArrayList>() + val adopts = CopyOnWriteArrayList>() var beforeCreate: suspend () -> Unit = {} var beforeRemove: suspend () -> Unit = {} var beforeRename: suspend () -> Unit = {} + var adoptResult: (String, String) -> RenameWorktreeResultDto = { path, name -> + RenameWorktreeResultDto(worktree = WorktreeDto(path, name, name, path)) + } var createResult: (CreateWorktreeRequestDto) -> CreateWorktreeResultDto = { req -> CreateWorktreeResultDto(WorktreeDto(req.branch, req.branch, req.branch, req.branch)) } @@ -69,4 +73,10 @@ class FakeWorktreeRpcApi : KiloWorktreeRpcApi { beforeRename() return renameResult(path, name) } + + override suspend fun adopt(directory: String, path: String, name: String): RenameWorktreeResultDto { + assertNotEdt("adopt") + adopts.add(Triple(directory, path, name)) + return adoptResult(path, name) + } } 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 0b04d417e3a..b8a0ddc28de 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 @@ -30,4 +30,14 @@ interface KiloWorktreeRpcApi : RemoteApi { suspend fun create(directory: String, request: CreateWorktreeRequestDto): CreateWorktreeResultDto suspend fun remove(directory: String, path: String, branch: String? = null, force: Boolean = false): RemoveWorktreeResultDto suspend fun rename(directory: String, path: String, name: String): RenameWorktreeResultDto + + /** + * Sets the worktree's stored display name to [name] only when it still has the default name + * (no custom name recorded yet). Used to let the first agent-generated session title flow onto + * the worktree header without ever overriding a name the user chose. + * + * Returns the updated worktree when the name was adopted, or a result with a null worktree and + * null error when it was skipped because a custom name already exists. + */ + suspend fun adopt(directory: String, path: String, name: String): RenameWorktreeResultDto }