mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-21 05:52:35 +08:00
fix(jetbrains): adopt the agent session title onto worktrees, not the placeholder
Worktree name adoption grabbed the CLI's "New session - <ISO>" placeholder because it is non-blank, so the branch header and worktree list showed the timestamp instead of the agent-generated title. - Skip default/placeholder titles (mirrors CLI isDefaultTitle); adopt only the real agent title once it arrives - Overlay live open-session titles in the worktree session list so the name updates as it streams in; render placeholders as a friendly "New session" - Notify on single WorktreeNameCache changes and apply them to the worktree list in place so both lists update live when a name is adopted or renamed
This commit is contained in:
@@ -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.
|
||||
+27
@@ -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
|
||||
}
|
||||
|
||||
+42
@@ -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()
|
||||
|
||||
+3
@@ -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<WorktreeNameCache>().addListener(this) { path, name -> controller.applyName(path, name) }
|
||||
ActionManager.getInstance().getAction("RenameElement")?.shortcutSet?.let { set ->
|
||||
edit.registerCustomShortcutSet(set, list, this)
|
||||
}
|
||||
|
||||
+7
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
+13
@@ -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)
|
||||
|
||||
+30
-5
@@ -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<String, String>()
|
||||
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<WorktreeDto>) {
|
||||
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) }
|
||||
}
|
||||
}
|
||||
|
||||
+78
-2
@@ -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<SessionUiFactory>().scope(),
|
||||
private val adopt: suspend (String, String, String) -> RenameWorktreeResultDto = { dir, path, name ->
|
||||
service<KiloWorktreeService>().adopt(dir, path, name)
|
||||
},
|
||||
private val onAdopted: (WorktreeDto) -> Unit = { updated ->
|
||||
service<WorktreeNameCache>().put(updated)
|
||||
if (!project.isDisposed) {
|
||||
project.service<KiloVfsManager>().updatePresentation(WorktreeSessionEditorKind.ID, worktreeSessionParams(updated))
|
||||
}
|
||||
},
|
||||
) : SessionHost(project, worktree, create, resolve, status, timers, request) {
|
||||
private val right = JPanel(BorderLayout())
|
||||
private val deleting = linkedSetOf<String>()
|
||||
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 - <ISO>"
|
||||
* 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 - <ISO>" / "Child session - <ISO>" 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)
|
||||
|
||||
+12
-3
@@ -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<ActiveListBadge> get() = listOfNotNull(kind?.let { ActiveListBadge(it.label(), it.style()) })
|
||||
override val section: String get() = HistoryTime.title(HistoryTime.section(item))
|
||||
|
||||
+45
@@ -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<Pair<String, String?>>()
|
||||
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)
|
||||
|
||||
|
||||
+64
-1
@@ -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<String>()
|
||||
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<Triple<String, String, String>>()
|
||||
val adoptedNames = mutableListOf<String>()
|
||||
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<String>()
|
||||
val adoptedNames = mutableListOf<String>()
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+21
@@ -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<String, SessionActivityKind>()
|
||||
var live = emptyMap<String, String>()
|
||||
val deletingIds = mutableSetOf<String>()
|
||||
val refs = mutableListOf<String>()
|
||||
val focuses = mutableListOf<Boolean>()
|
||||
@@ -437,6 +456,8 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() {
|
||||
|
||||
override fun activity(): Map<String, SessionActivityKind> = kinds
|
||||
|
||||
override fun titles(): Map<String, String> = live
|
||||
|
||||
override fun deleting(): Set<String> = deletingIds
|
||||
|
||||
override fun newSession() {
|
||||
|
||||
+22
@@ -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"))
|
||||
}
|
||||
}
|
||||
+10
@@ -22,9 +22,13 @@ class FakeWorktreeRpcApi : KiloWorktreeRpcApi {
|
||||
val removes = CopyOnWriteArrayList<Triple<String, String, String?>>()
|
||||
val removeForces = CopyOnWriteArrayList<Boolean>()
|
||||
val renames = CopyOnWriteArrayList<Triple<String, String, String>>()
|
||||
val adopts = CopyOnWriteArrayList<Triple<String, String, String>>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,4 +30,14 @@ interface KiloWorktreeRpcApi : RemoteApi<Unit> {
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user