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:
kirillk
2026-07-30 15:55:23 -04:00
parent ee9a441ccc
commit a678e549f5
15 changed files with 389 additions and 11 deletions
@@ -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.
@@ -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
}
@@ -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()
@@ -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)
}
@@ -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")
}
}
@@ -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)
@@ -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) }
}
}
@@ -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)
@@ -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))
@@ -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)
@@ -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,
)
}
@@ -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() {
@@ -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"))
}
}
@@ -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
}