fix(jetbrains): show worktree progress in agent rows

This commit is contained in:
kirillk
2026-08-24 09:42:30 -04:00
parent 32573a0a1d
commit ce5a71e5f0
27 changed files with 342 additions and 259 deletions
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Show worktree creation and move progress in the Agent Manager row instead of the chat dock.
@@ -99,6 +99,7 @@ internal class KiloToolWindowSetupService(
workspace.directory,
cs,
activity = project.service<KiloSessionService>().activity,
abort = { id, dir -> project.service<KiloSessionService>().abort(id, dir) },
)
val agentManagerPanel = AgentManagerPanel(manager, worktrees, project)
@@ -138,13 +139,17 @@ internal class KiloToolWindowSetupService(
agentContent.putUserData(ToolWindow.SHOW_CONTENT_ICON, true)
toolWindow.contentManager.addContent(chatContent)
toolWindow.contentManager.addContent(agentContent)
val agents = { toolWindow.contentManager.setSelectedContent(agentContent, true) }
// The chat branch dock's "New Worktree" action switches to the Agent Manager tab and
// opens its New Worktree dialog, matching the VS Code sidebar dock.
manager.onNewWorktree = {
Telemetry.send("New Worktree Clicked", mapOf("surface" to "chat_dock"))
agentManagerPanel.configure {
toolWindow.contentManager.setSelectedContent(agentContent)
}
agents()
agentManagerPanel.configure()
}
manager.onMoveToWorktree = { id, dir ->
agents()
agentManagerPanel.move(id, dir)
}
val listener = object : ContentManagerListener {
override fun selectionChanged(event: ContentManagerEvent) {
@@ -2,19 +2,16 @@ package ai.kilocode.client.actions
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.ui.header.ChatDockKeys
import ai.kilocode.rpc.dto.MoveStage
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
import com.intellij.openapi.util.IconLoader
import com.intellij.ui.AnimatedIcon
/**
* "Move to Worktree" action shown in the chat branch dock. Visible only when there is something to
* move (a conversation or local changes). While a move runs it stays visible but disabled, showing a
* spinner and the current stage.
* move (a conversation or local changes).
*/
class ChatMoveToWorktreeAction : AnAction(), DumbAware {
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT
@@ -26,14 +23,6 @@ class ChatMoveToWorktreeAction : AnAction(), DumbAware {
e.presentation.isEnabledAndVisible = false
return
}
if (dock.moving()) {
e.presentation.isVisible = true
e.presentation.isEnabled = false
e.presentation.icon = AnimatedIcon.Default.INSTANCE
e.presentation.text = progressLabel(dock.moveStage())
e.presentation.description = null
return
}
e.presentation.isEnabledAndVisible = dock.moveEnabled()
e.presentation.icon = BRANCH
e.presentation.text = KiloBundle.message("session.dock.move")
@@ -44,14 +33,6 @@ class ChatMoveToWorktreeAction : AnAction(), DumbAware {
e.getData(ChatDockKeys.DOCK)?.triggerMove()
}
private fun progressLabel(stage: MoveStage?): String = when (stage) {
MoveStage.CAPTURING -> KiloBundle.message("session.dock.progress.capturing")
MoveStage.CREATING -> KiloBundle.message("session.dock.progress.creating")
MoveStage.TRANSFERRING -> KiloBundle.message("session.dock.progress.transferring")
MoveStage.FORKING -> KiloBundle.message("session.dock.progress.forking")
else -> KiloBundle.message("session.dock.move")
}
private fun moveTooltip(count: Int): String = when (count) {
0 -> KiloBundle.message("session.dock.move.tooltip.empty")
1 -> KiloBundle.message("session.dock.move.tooltip.one")
@@ -13,7 +13,7 @@ import ai.kilocode.client.agentManager.worktree.WorktreeEditorMatchers
import ai.kilocode.client.agentManager.worktree.WorktreeSessionEditorMatcher
import ai.kilocode.client.agentManager.worktree.WorktreeSessionEditorKind
import ai.kilocode.client.agentManager.worktree.WorktreeTitle
import ai.kilocode.client.agentManager.worktree.ensureWorktreeSessionEditorKind
import ai.kilocode.client.agentManager.worktree.openWorktreeSession
import ai.kilocode.client.agentManager.worktree.normalizeWorktreePath
import ai.kilocode.client.agentManager.worktree.worktreeSessionParams
import ai.kilocode.client.ui.prTooltip
@@ -95,7 +95,7 @@ class AgentManagerPanel(
(row as? WorktreeRow)?.dto?.takeIf { canRename(it) || canDelete(it) || canOpenPr(it) || canOpenDiff(it) }
}),
reorder = ActiveListReorder(
movable = { row -> row is WorktreeRow && !row.current && !row.pending && !row.deleting },
movable = { row -> row is WorktreeRow && !row.current && row.progress == null },
onMove = { move -> controller.reorder(move.keys) },
),
)
@@ -114,9 +114,10 @@ class AgentManagerPanel(
// Focus the list so the freshly created worktree renders as an active selection rather
// than the inactive highlight it would get while focus stays on the toolbar.
if (list.select(key)) list.focusList()
item(key)?.takeIf { !controller.isPending(it.id) }?.let { open(it, focus = false) }
item(key)?.takeIf { controller.progress(it.id) == null }?.let { open(it, focus = false) }
}
controller.onCreateFailure = { err -> notifyCreateFailed(err) }
controller.onMoveFailure = { err -> notifyMoveFailed(err) }
controller.onRemoveSuccess = { item, index -> onRemoved(item, index) }
controller.onActivityChanged = {
sync()
@@ -155,7 +156,7 @@ class AgentManagerPanel(
}
/** Opens the New Worktree dialog. */
fun configure(afterClose: (() -> Unit)? = null) {
fun configure() {
val target = project ?: return
NewWorktreeDialog(
this,
@@ -168,9 +169,10 @@ class AgentManagerPanel(
controller.create(branch, base, prompt = prompt)
},
).show()
afterClose?.invoke()
}
internal fun move(sessionId: String, directory: String) = controller.move(sessionId, directory)
private fun remove(item: WorktreeDto, force: Boolean) {
controller.remove(item, force, onFailure = { result -> notifyFailed(item, result, force) })
}
@@ -205,9 +207,8 @@ class AgentManagerPanel(
private fun open(item: WorktreeDto, focus: Boolean) {
val target = project ?: return
if (controller.isPending(item.id)) return
ensureWorktreeSessionEditorKind()
target.service<KiloVfsManager>().open(WorktreeSessionEditorKind.ID, worktreeSessionParams(item), focus)
if (controller.progress(item.id) != null) return
openWorktreeSession(target, item, controller.takeSession(item.id), focus)
}
private fun close(item: WorktreeDto) {
@@ -226,13 +227,13 @@ class AgentManagerPanel(
/** The PR URL for [item], or null when it has none or is not in a stable, openable state. */
private fun prUrl(item: WorktreeDto?): String? {
if (item == null || item.main) return null
if (controller.isPending(item.id) || controller.isDeleting(item.id)) return null
if (controller.progress(item.id) != null) return null
return prs[normalizeWorktreePath(item.path)]?.url
}
internal fun canOpenDiff(item: WorktreeDto?): Boolean {
if (item == null || item.main || project == null) return false
return !controller.isPending(item.id) && !controller.isDeleting(item.id)
return controller.progress(item.id) == null
}
internal fun openDiff(item: WorktreeDto) {
@@ -255,8 +256,7 @@ class AgentManagerPanel(
}
private fun deletable(item: WorktreeDto?): Boolean {
if (!worktreeDeletable(item, item?.id?.let(controller::isPending) == true)) return false
return item?.id?.let(controller::isDeleting) != true
return worktreeDeletable(item, item?.id?.let(controller::progress) != null)
}
private fun renameable(item: WorktreeDto?): Boolean {
@@ -266,7 +266,7 @@ class AgentManagerPanel(
private fun renameVisible(item: WorktreeDto?): Boolean {
if (item == null || item.main) return false
return !controller.isPending(item.id) && !controller.isDeleting(item.id)
return controller.progress(item.id) == null
}
/**
@@ -311,6 +311,10 @@ class AgentManagerPanel(
KiloNotifications.error(project, KiloBundle.message("worktree.create.failed.title"), err)
}
private fun notifyMoveFailed(err: String?) {
KiloNotifications.error(project, KiloBundle.message("worktree.move.failed.title"), err)
}
/** Surfaces a failed removal; offers a force-delete retry when git reported a lock. */
private fun notifyFailed(item: WorktreeDto, result: RemoveWorktreeResultDto, forced: Boolean) {
val title = KiloBundle.message("worktree.delete.failed.title", item.name)
@@ -352,8 +356,7 @@ class AgentManagerPanel(
val current = controller.current?.let { item ->
WorktreeRow(
item,
pending = false,
deleting = false,
progress = null,
kind = controller.kind(item.path),
stats = null,
pr = null,
@@ -368,8 +371,7 @@ class AgentManagerPanel(
service<WorktreeNameCache>().putPr(item.path, pull)
WorktreeRow(
item,
controller.isPending(item.id),
controller.isDeleting(item.id),
controller.progress(item.id),
controller.kind(item.path),
stats[key],
pull,
@@ -418,6 +420,7 @@ class AgentManagerPanel(
override fun dispose() {
controller.onSelect = null
controller.onCreateFailure = null
controller.onMoveFailure = null
controller.onRemoveSuccess = null
controller.onActivityChanged = null
}
@@ -469,8 +472,7 @@ class AgentManagerPanel(
*/
private inner class WorktreeRow(
val dto: WorktreeDto,
val pending: Boolean,
override val deleting: Boolean,
override val progress: String?,
val kind: SessionActivityKind?,
val stats: WorktreeStatsDto?,
val pr: WorktreePrDto?,
@@ -481,13 +483,13 @@ class AgentManagerPanel(
override val title: String get() = if (current) dto.branch else WorktreeTitle.text(dto.name, dto.path, pr)
override val description: String get() = WorktreeTitle.fallback(dto.path)
override val tooltip: String? get() = null
override val icon = WorktreeIcons.forRow(pending, kind, dto.locked, current)
override val icon = WorktreeIcons.forRow(progress != null, kind, dto.locked, current)
override val section: String? get() = if (current) null else KiloBundle.message("worktree.section.local")
override val search: String get() = listOfNotNull(dto.name, dto.branch, dto.path, dto.lockReason).joinToString(" ")
private val customName: String? get() = WorktreeTitle.custom(dto.name, dto.path)
override val metrics: ActiveListMetrics?
get() {
if (pending || deleting) return null
if (progress != null) return null
val s = stats
val p = pr
if (s == null && p == null) return null
@@ -506,8 +508,7 @@ class AgentManagerPanel(
override fun equals(other: Any?): Boolean {
val row = other as? WorktreeRow ?: return false
return dto == row.dto &&
pending == row.pending &&
deleting == row.deleting &&
progress == row.progress &&
kind == row.kind &&
stats == row.stats &&
pr == row.pr &&
@@ -516,8 +517,7 @@ class AgentManagerPanel(
override fun hashCode(): Int {
var result = dto.hashCode()
result = 31 * result + pending.hashCode()
result = 31 * result + deleting.hashCode()
result = 31 * result + (progress?.hashCode() ?: 0)
result = 31 * result + (kind?.hashCode() ?: 0)
result = 31 * result + (stats?.hashCode() ?: 0)
result = 31 * result + (pr?.hashCode() ?: 0)
@@ -537,4 +537,4 @@ class AgentManagerPanel(
}
}
internal fun worktreeDeletable(item: WorktreeDto?, pending: Boolean): Boolean = item != null && !item.main && !pending
internal fun worktreeDeletable(item: WorktreeDto?, busy: Boolean): Boolean = item != null && !item.main && !busy
@@ -1,72 +0,0 @@
package ai.kilocode.client.agentManager.worktree
import ai.kilocode.client.KiloNotifications
import ai.kilocode.client.app.KiloSessionService
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.telemetry.Telemetry
import ai.kilocode.client.vfs.KiloVfsManager
import ai.kilocode.log.KiloLog
import ai.kilocode.rpc.dto.MoveProgressDto
import ai.kilocode.rpc.dto.MoveStage
import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
import com.intellij.util.concurrency.annotations.RequiresEdt
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Sidebar "Move to Worktree": aborts the running session, generates a friendly branch name, then
* drives the backend move flow (capture → create → transfer → fork). On success it opens the
* worktree session editor on the forked session; on error it notifies through the worktree pattern.
* The source sidebar session stays in the sidebar (aborted, not deleted), matching VS Code fork
* semantics.
*/
internal class MoveToWorktree(
private val project: Project,
private val cs: CoroutineScope,
) {
private companion object {
private val LOG = KiloLog.create(MoveToWorktree::class.java)
}
/** [progress] is invoked on the EDT for each stage so the dock button can reflect it. */
@RequiresEdt
fun launch(directory: String, sessionId: String, progress: (MoveStage, String?) -> Unit) {
cs.launch {
runCatching { service<KiloSessionService>().abort(sessionId, directory) }
.onFailure { LOG.info("worktree move: abort failed (session may be idle): ${it.message}") }
val known = service<KiloWorktreeService>().listBranches(directory).branches.toSet()
val branch = WorktreeNames.generate(known)
var stage = MoveStage.CAPTURING
val flow = service<KiloWorktreeService>().moveToWorktree(directory, sessionId, branch)
flow.collect { event ->
if (event.stage != MoveStage.ERROR) stage = event.stage
withContext(Dispatchers.Main) { handle(event, stage, progress) }
}
}
}
@RequiresEdt
private fun handle(event: MoveProgressDto, failing: MoveStage, progress: (MoveStage, String?) -> Unit) {
progress(event.stage, event.detail)
when (event.stage) {
MoveStage.DONE -> {
val worktree = event.worktree ?: return
ensureWorktreeSessionEditorKind()
project.service<KiloVfsManager>().open(
WorktreeSessionEditorKind.ID,
worktreeSessionParams(worktree, session = event.session),
)
Telemetry.send("Continue in Worktree", mapOf("surface" to "sidebar"))
}
MoveStage.ERROR -> {
LOG.warn("worktree move failed stage=$failing error=${event.error}")
KiloNotifications.error(project, KiloBundle.message("session.dock.move.failed.title"), event.error)
Telemetry.send("Continue in Worktree Failed", mapOf("stage" to failing.name))
}
else -> Unit
}
}
}
@@ -6,18 +6,19 @@ import ai.kilocode.client.util.edt
import ai.kilocode.client.session.SessionActivityKind
import ai.kilocode.rpc.dto.CreateWorktreeRequestDto
import ai.kilocode.rpc.dto.CreateWorktreeResultDto
import ai.kilocode.rpc.dto.MoveStage
import ai.kilocode.rpc.dto.RemoveWorktreeResultDto
import ai.kilocode.rpc.dto.SessionActivityDto
import ai.kilocode.rpc.dto.WorktreeDto
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.components.service
import com.intellij.ui.CollectionListModel
import com.intellij.util.concurrency.annotations.RequiresEdt
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import java.util.Collections
/**
* Owns the worktree list model and drives the [KiloWorktreeService] off the EDT. Model mutations
@@ -28,13 +29,17 @@ class WorktreeController(
val directory: String,
private val cs: CoroutineScope,
activity: StateFlow<Map<String, SessionActivityDto>> = MutableStateFlow(emptyMap()),
private val abort: suspend (String, String) -> Unit = { _, _ -> },
private val telemetry: (String, Map<String, String>) -> Unit = { event, props -> Telemetry.send(event, props) },
) {
val model = CollectionListModel<WorktreeDto>()
private val pending = LinkedHashMap<String, WorktreeDto>()
private val deleting = Collections.synchronizedSet(LinkedHashSet<String>())
private val tasks = LinkedHashMap<String, String>()
private val moves = LinkedHashSet<String>()
private val sessions = LinkedHashMap<String, String>()
var onSelect: ((String) -> Unit)? = null
var onCreateFailure: ((String?) -> Unit)? = null
var onMoveFailure: ((String?) -> Unit)? = null
var onRemoveSuccess: ((WorktreeDto, Int) -> Unit)? = null
var onActivityChanged: (() -> Unit)? = null
@@ -73,7 +78,9 @@ class WorktreeController(
fun isPending(id: String): Boolean = id in pending
fun isDeleting(id: String): Boolean = id in deleting
fun progress(id: String): String? = tasks[id]
fun takeSession(id: String): String? = sessions.remove(id)
fun kind(path: String): SessionActivityKind? = kinds[normalizeWorktreePath(path)]
@@ -116,6 +123,7 @@ class WorktreeController(
val temp = WorktreeDto(id, branch, branch, id)
edt {
pending[temp.id] = temp
tasks[temp.id] = KiloBundle.message("worktree.progress.creating")
model.add(temp)
onSelect?.invoke(temp.id)
}
@@ -130,6 +138,7 @@ class WorktreeController(
val temp = WorktreeDto(id, KiloBundle.message("worktree.import.pr.section"), "", id)
edt {
pending[temp.id] = temp
tasks[temp.id] = KiloBundle.message("worktree.progress.creating")
model.add(temp)
onSelect?.invoke(temp.id)
}
@@ -148,6 +157,7 @@ class WorktreeController(
val created = result.worktree
edt {
pending.remove(temp.id)
tasks.remove(temp.id)
val idx = model.getElementIndex(temp)
if (created != null) {
if (idx >= 0) model.setElementAt(created, idx) else model.add(created)
@@ -174,13 +184,14 @@ class WorktreeController(
onSuccess: () -> Unit = {},
onFailure: (RemoveWorktreeResultDto) -> Unit = {},
) {
if (!deleting.add(dto.id)) return
if (dto.id in tasks) return
tasks[dto.id] = KiloBundle.message("common.deleting")
edt { refresh(dto) }
cs.launch {
val result = service.remove(directory, dto.path, dto.branch, force)
if (result.ok) {
edt {
deleting.remove(dto.id)
tasks.remove(dto.id)
val index = model.getElementIndex(dto)
model.remove(dto)
cache().remove(dto.path)
@@ -193,7 +204,7 @@ class WorktreeController(
// Removal failed: git still tracks the worktree. Keep the row and reconcile with
// ground truth so a stale optimistic delete can't make the entry reappear later.
edt {
deleting.remove(dto.id)
tasks.remove(dto.id)
refresh(dto)
telemetry(
"Worktree Delete Failed",
@@ -205,6 +216,46 @@ class WorktreeController(
}
}
@RequiresEdt
fun move(sessionId: String, source: String = directory) {
if (!moves.add(sessionId)) return
val branch = suggestName()
val temp = WorktreeDto("pending:$branch:${System.nanoTime()}", branch, branch, "pending:$branch")
pending[temp.id] = temp
tasks[temp.id] = label(MoveStage.CAPTURING)
model.add(temp)
onSelect?.invoke(temp.id)
cs.launch {
var stage = MoveStage.CAPTURING
runCatching {
abort(sessionId, source)
service.moveToWorktree(source, sessionId, branch).collect { event ->
edt {
if (event.stage != MoveStage.ERROR) stage = event.stage
tasks[temp.id] = label(event.stage)
refresh(temp)
when (event.stage) {
MoveStage.DONE -> {
moves.remove(sessionId)
pending.remove(temp.id)
tasks.remove(temp.id)
val worktree = event.worktree ?: return@edt
val idx = model.getElementIndex(temp)
if (idx >= 0) model.setElementAt(worktree, idx) else model.add(worktree)
cache().put(worktree)
event.session?.let { sessions[worktree.id] = it }
onSelect?.invoke(worktree.id)
telemetry("Continue in Worktree", mapOf("surface" to "sidebar"))
}
MoveStage.ERROR -> failMove(sessionId, temp, event.error, stage)
else -> Unit
}
}
}
}.onFailure { err -> edt { failMove(sessionId, temp, err.message, stage) } }
}
}
fun rename(
dto: WorktreeDto,
name: String,
@@ -281,6 +332,24 @@ class WorktreeController(
return (0 until model.size).firstOrNull { model.getElementAt(it).id == id } ?: -1
}
private fun failMove(session: String, temp: WorktreeDto, err: String?, stage: MoveStage) {
moves.remove(session)
pending.remove(temp.id)
tasks.remove(temp.id)
model.remove(temp)
onMoveFailure?.invoke(err)
telemetry("Continue in Worktree Failed", mapOf("stage" to stage.name))
}
private fun label(stage: MoveStage): String = when (stage) {
MoveStage.CAPTURING -> KiloBundle.message("worktree.progress.capturing")
MoveStage.CREATING -> KiloBundle.message("worktree.progress.creating")
MoveStage.TRANSFERRING -> KiloBundle.message("worktree.progress.transferring")
MoveStage.FORKING -> KiloBundle.message("worktree.progress.starting")
MoveStage.DONE -> ""
MoveStage.ERROR -> ""
}
private fun cache(): WorktreeNameCache {
return ApplicationManager.getApplication().service()
}
@@ -22,16 +22,16 @@ internal object WorktreeIcons {
/**
* Leading icon for a worktree row. At rest the row shows what it is — the local machine, a locked
* checkout, or a branch checkout — while a running or waiting session takes the slot over so the
* list still surfaces activity at a glance. Creation ([pending]) outranks all of it, and an
* errored session falls back to the resting glyph instead of shouting in the leading slot.
* list still surfaces activity at a glance. An operation on the row ([busy]) outranks all of it,
* and an errored session falls back to the resting glyph instead of shouting in the leading slot.
*/
fun forRow(
pending: Boolean,
busy: Boolean,
kind: SessionActivityKind? = null,
locked: Boolean = false,
current: Boolean = false,
): Icon {
if (pending) return spinner
if (busy) return spinner
return when (kind) {
SessionActivityKind.RUNNING -> running
SessionActivityKind.QUESTION,
@@ -7,6 +7,7 @@ import ai.kilocode.client.session.SessionUiFactory
import ai.kilocode.client.vfs.KiloEditorKind
import ai.kilocode.client.vfs.KiloEditorKindRegistry
import ai.kilocode.client.vfs.KiloVirtualFile
import ai.kilocode.client.vfs.KiloVfsManager
import ai.kilocode.rpc.dto.WorktreeDto
import com.intellij.openapi.Disposable
import com.intellij.openapi.components.service
@@ -62,3 +63,8 @@ internal fun unregisterWorktreeSessionEditorKind() {
internal fun worktreeSessionParams(item: WorktreeDto, session: String? = null): Map<String, String> = linkedMapOf(
"path" to item.path,
).apply { session?.takeIf { it.isNotBlank() }?.let { put("session", it) } }
internal fun openWorktreeSession(project: Project, worktree: WorktreeDto, session: String? = null, focus: Boolean = true) {
ensureWorktreeSessionEditorKind()
project.service<KiloVfsManager>().open(WorktreeSessionEditorKind.ID, worktreeSessionParams(worktree, session), focus)
}
@@ -557,7 +557,7 @@ class WorktreeSessionEditorPanel(
private inner class SessionRow(
val session: SessionDto,
val kind: SessionActivityKind?,
override val deleting: Boolean = false,
private 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,
@@ -572,11 +572,9 @@ class WorktreeSessionEditorPanel(
return name
}
override val tooltip: String get() = title
override val progress: String? get() = if (deleting) KiloBundle.message("common.deleting") else null
override val badges: List<ActiveListBadge>
get() {
if (deleting) return emptyList()
return listOfNotNull(kind?.let { ActiveListBadge(it.label(), it.style()) })
}
get() = listOfNotNull(kind?.let { ActiveListBadge(it.label(), it.style()) })
override val section: String get() = HistoryTime.title(HistoryTime.section(item))
override val search: String get() = listOf(session.title, session.id, session.directory).joinToString(" ")
}
@@ -21,6 +21,12 @@ interface SessionManager {
/** Opens the New Worktree flow. No-op unless [supportsNewWorktree] is true. */
fun newWorktree() {}
/** Whether this surface can move the current chat into a worktree (sidebar only). */
val supportsMoveToWorktree: Boolean get() = false
/** Opens the Move to Worktree flow. No-op unless [supportsMoveToWorktree] is true. */
fun moveToWorktree(sessionId: String, directory: String) {}
fun showHistory(back: (() -> Unit)? = null)
fun openSession(ref: SessionRef)
@@ -52,12 +52,21 @@ class SessionSidePanelManager(
/** Wired by the tool window to open the Agent Manager's New Worktree flow from the chat dock. */
var onNewWorktree: (() -> Unit)? = null
/** Wired by the tool window to move the current chat into an Agent Manager worktree row. */
var onMoveToWorktree: ((String, String) -> Unit)? = null
override val supportsNewWorktree: Boolean get() = onNewWorktree != null
override val supportsMoveToWorktree: Boolean get() = onMoveToWorktree != null
override fun newWorktree() {
onNewWorktree?.invoke()
}
override fun moveToWorktree(sessionId: String, directory: String) {
onMoveToWorktree?.invoke(sessionId, directory)
}
val defaultFocusedComponent: JComponent? get() = currentUi()?.defaultFocusedComponent ?: (panel as? HistoryPanel)?.defaultFocusedComponent
@RequiresEdt
@@ -44,7 +44,6 @@ import ai.kilocode.client.session.ui.attachment.attachmentParams
import ai.kilocode.client.session.ui.attachment.ensureAttachmentEditorKind
import ai.kilocode.client.session.ui.attachment.isEmbeddedAttachment
import ai.kilocode.client.agentManager.worktree.KiloWorktreeService
import ai.kilocode.client.agentManager.worktree.MoveToWorktree
import ai.kilocode.client.session.ui.header.BranchDock
import ai.kilocode.client.session.ui.header.SessionHeaderPanel
import ai.kilocode.client.session.ui.selection.SessionContextMenu
@@ -201,8 +200,6 @@ class SessionUi(
private var bottom: JComponent? = null
private val move by lazy { MoveToWorktree(project, cs) }
internal lateinit var scroll: SessionScroll
private lateinit var question: QuestionView
@@ -430,7 +427,8 @@ class SessionUi(
if (!readonly && showBranchDock()) {
val owner = manager
val newWorktree = if (owner?.supportsNewWorktree == true) owner::newWorktree else null
dock = BranchDock(openDiff = ::openBranchChanges, onMove = ::moveToWorktree, onNewWorktree = newWorktree)
val move = if (owner?.supportsMoveToWorktree == true) ::moveToWorktree else null
dock = BranchDock(openDiff = ::openBranchChanges, onMove = move, onNewWorktree = newWorktree)
}
scroll = SessionScroll(root, sessionContent, messageBody, blankBody)
@@ -990,12 +988,11 @@ class SessionUi(
}
}
/** Starts the Move to Worktree flow for the current session, wiring progress into the dock. */
/** Starts the Move to Worktree flow for the current session through the side-panel manager. */
@RequiresEdt
private fun moveToWorktree() {
val dock = dock ?: return
val id = controller.id ?: return
move.launch(controller.sessionDirectory, id) { stage, detail -> dock.setMoveProgress(stage, detail) }
manager?.moveToWorktree(id, controller.sessionDirectory)
}
@RequiresEdt
@@ -1,5 +1,6 @@
package ai.kilocode.client.session.history
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.SessionActivityKind
import ai.kilocode.client.ui.list.ActiveListBadge
import ai.kilocode.client.ui.list.ActiveListCell
@@ -12,22 +13,17 @@ internal data class LocalHistoryRow(
private val text: String,
private val kind: SessionActivityKind?,
override val section: String?,
override val deleting: Boolean,
private val deleting: Boolean,
) : ActiveListItem {
override val key: String get() = item.id
override val title: String get() = text
override val trailing: String get() = HistoryTime.relative(item)
override val search: String get() = listOfNotNull(text, item.id, item.directory).joinToString(" ")
override val progress: String? get() = if (deleting) KiloBundle.message("common.deleting") else null
override val badges: List<ActiveListBadge>
get() {
if (deleting) return emptyList()
return listOfNotNull(kind?.let { ActiveListBadge(it.label(), it.style()) })
}
get() = listOfNotNull(kind?.let { ActiveListBadge(it.label(), it.style()) })
override val cells: List<ActiveListCell>
get() {
if (deleting) return emptyList()
return listOf(activeListRenameCell(), activeListDeleteCell())
}
get() = listOf(activeListRenameCell(), activeListDeleteCell())
}
internal data class CloudHistoryRow(
@@ -11,7 +11,6 @@ import ai.kilocode.client.ui.layout.align
import ai.kilocode.rpc.dto.BranchStatusDto
import ai.kilocode.rpc.dto.DiffFileDto
import ai.kilocode.rpc.dto.GhAvailability
import ai.kilocode.rpc.dto.MoveStage
import com.intellij.ide.ActivityTracker
import com.intellij.openapi.actionSystem.ActionManager
import com.intellij.openapi.actionSystem.DataSink
@@ -37,12 +36,12 @@ import java.awt.Dimension
* not enabled, so the dock never shows a lone action. The dock exposes its state to those actions via
* [ChatDockKeys.DOCK]. Collapses to nothing unless it has a PR, changes, or an enabled action.
*
* The action row is offered only while the session is idle: an active turn ([setBusy]) withdraws it,
* a move in progress keeps it. The PR row stays through a turn — it is informational, not an action.
* The action row is offered only while the session is idle: an active turn ([setBusy]) withdraws it.
* The PR row stays through a turn — it is informational, not an action.
*/
internal class BranchDock(
openDiff: () -> Unit,
private val onMove: () -> Unit,
private val onMove: (() -> Unit)?,
private val onNewWorktree: (() -> Unit)? = null,
) : BorderLayoutPanel(), SessionEditorStyleTarget, UiDataProvider {
private val core = PrHeaderView(openDiff)
@@ -60,8 +59,6 @@ internal class BranchDock(
private var branch: BranchStatusDto? = null
private var hasMessages = false
private var busy = false
private var moving = false
private var stage: MoveStage? = null
init {
isOpaque = true
@@ -113,28 +110,17 @@ internal class BranchDock(
sync()
}
@RequiresEdt
fun setMoveProgress(stage: MoveStage, detail: String?) {
moving = stage != MoveStage.DONE && stage != MoveStage.ERROR
this.stage = if (moving) stage else null
sync()
}
// ---- state read by the toolbar actions ----
fun newWorktreeEnabled(): Boolean = onNewWorktree != null && dockActive() && !moving
fun newWorktreeEnabled(): Boolean = onNewWorktree != null && dockActive()
fun moveEnabled(): Boolean = dockActive() && !moving
fun moving(): Boolean = moving
fun moveStage(): MoveStage? = stage
fun moveEnabled(): Boolean = onMove != null && dockActive()
fun changeCount(): Int = files.size
fun triggerNewWorktree() = onNewWorktree?.invoke() ?: Unit
fun triggerMove() = onMove()
fun triggerMove() = onMove?.invoke() ?: Unit
private fun dockActive(): Boolean = gitAvailable() && !busy && (hasMessages || files.isNotEmpty())
@@ -151,7 +137,7 @@ internal class BranchDock(
// PR present -> the informational PR header; otherwise the centered action row.
core.isVisible = pull != null
val rowVisible = pull == null && (dockActive() || moving)
val rowVisible = pull == null && dockActive()
actionRow.isVisible = rowVisible
val next = pull != null || rowVisible
@@ -109,7 +109,8 @@ internal interface ActiveListItem {
val metrics: ActiveListMetrics? get() = null
val cells: List<ActiveListCell> get() = emptyList()
val disabled: Boolean get() = false
val deleting: Boolean get() = false
/** Non-null while a background operation owns this row; the text is shown trailing. */
val progress: String? get() = null
/** Extra text matched by the filter field in addition to [title]; null matches title only. */
val search: String? get() = null
}
@@ -126,7 +127,7 @@ internal fun activeListVisibleCells(
menu: Boolean = false,
): List<ActiveListCell> {
if (item.disabled) return emptyList()
if (item.deleting) return emptyList()
if (item.progress != null) return emptyList()
val cells = item.cells.filter { active || it.alwaysVisible }
if (!menu) return cells
return cells + activeListMenuCell()
@@ -1,7 +1,6 @@
package ai.kilocode.client.ui.list
import ai.kilocode.client.agentManager.worktree.WorktreeStatsView
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.ui.PickerRow
import ai.kilocode.client.ui.FilledBadgeIcon
import ai.kilocode.client.ui.LayeredOverlayPanel
@@ -192,7 +191,7 @@ internal class ActiveListRenderer(
val active = selected && (focused || list.hasFocus() || (list as? ActiveListActive)?.active() == true)
val fg = UIUtil.getListForeground(active, active || focused)
val weak = UiStyle.Colors.weak()
val titleFg = if (value.deleting) weak else fg
val titleFg = if (value.progress != null) weak else fg
val section = activeListSectionTitle(model.items, index)
background = list.background
@@ -239,13 +238,13 @@ internal class ActiveListRenderer(
JBUI.Borders.empty()
}
desc.foreground = weak
val data = if (value.deleting) null else value.metrics
val data = if (value.progress != null) null else value.metrics
metrics.update(data?.let { WorktreeStatsDto("", it.additions, it.deletions, it.ahead, it.behind) }, data?.pr, data?.prTooltip ?: data?.pr?.text)
metrics.setActions(data?.onChanges, data?.onPr)
val end = if (value.deleting) KiloBundle.message("common.deleting") else value.trailing.orEmpty()
val end = value.progress ?: value.trailing.orEmpty()
trail.text = end
trail.isVisible = end.isNotBlank() && data == null
metrics.isVisible = data != null && !value.deleting
metrics.isVisible = data != null
// Hide the wrapper too so a metrics-less row does not reserve the trailing gap on its
// description, and collapse the whole second row when it would be empty so title-only rows
// stay vertically centered.
@@ -347,7 +346,7 @@ internal class ActiveListRenderer(
}
private fun syncBadges(item: ActiveListItem) {
val items = if (item.deleting) emptyList() else item.badges
val items = if (item.progress != null) emptyList() else item.badges
while (badges.componentCount > items.size) badges.remove(badges.componentCount - 1)
while (badges.componentCount < items.size) {
badges.add(JBLabel().apply {
@@ -144,7 +144,7 @@ internal class ActiveListView(
if (selection(e)) return
val hit = hit(e, enabled = false) ?: return
if (hit.id != null) return
if (hit.item.deleting) return
if (hit.item.progress != null) return
if (!openOnClick) {
onClick?.invoke(hit.item) ?: return
e.consume()
@@ -158,7 +158,7 @@ internal class ActiveListView(
if (e.clickCount != 2 || !UIUtil.isActionClick(e, MouseEvent.MOUSE_CLICKED, true)) return
val hit = hit(e, enabled = false) ?: return
if (hit.id != null) return
if (hit.item.deleting) return
if (hit.item.progress != null) return
val action = onOpen
if (action != null) action(hit.item, true) else activate(hit.item)
e.consume()
@@ -174,7 +174,7 @@ internal class ActiveListView(
press = null
val hit = hit(e) ?: return
if (hit.item.key != down.key || hit.id != down.id) return
if (hit.item.deleting) return
if (hit.item.progress != null) return
fire(hit.item, down.id)
e.consume()
}
@@ -517,7 +517,7 @@ internal class ActiveListView(
private fun open(focus: Boolean) {
val item = active() ?: return
if (item is ActiveListGap) return
if (item.deleting) return
if (item.progress != null) return
val action = onOpen
if (action != null) {
action(item, focus)
@@ -529,7 +529,7 @@ internal class ActiveListView(
private fun source() {
val item = list.selectedValue ?: return
if (item is ActiveListGap) return
if (item.deleting) return
if (item.progress != null) return
onOpen?.invoke(item, true)
}
@@ -548,7 +548,7 @@ internal class ActiveListView(
*/
private fun activate(item: ActiveListItem) {
if (item is ActiveListGap) return
if (item.deleting) return
if (item.progress != null) return
val action = onActivate
if (action != null) {
action(item)
@@ -565,7 +565,7 @@ internal class ActiveListView(
private fun primary(item: ActiveListItem) {
if (item is ActiveListGap) return
if (item.deleting) return
if (item.progress != null) return
val cells = activeListVisibleCells(item, true)
val cell = cells.firstOrNull { it.enabled && it.primary }
if (cell != null) {
@@ -606,7 +606,7 @@ internal class ActiveListView(
if (!list.isEnabled || filter.isNotBlank() || drag != null) return null
val idx = rowAt(point) ?: return null
val item = model.getElementAt(idx)
if (item is ActiveListGap || item.disabled || item.deleting) return null
if (item is ActiveListGap || item.disabled || item.progress != null) return null
if (!cfg.movable(item)) return null
val selected = list.isSelectedIndex(idx)
if (activeListCellAt(list, idx, point, selected, menu?.takeIf { it.available(item) } != null) != null) return null
@@ -746,7 +746,7 @@ internal class ActiveListView(
if (!bounds.contains(point)) return false
val item = model.getElementAt(idx)
if (item is ActiveListGap) return false
if (item.disabled || item.deleting || !cfg.available(item)) return false
if (item.disabled || item.progress != null || !cfg.available(item)) return false
val rect = activeListCellBounds(list, idx, list.isSelectedIndex(idx))[ACTIVE_LIST_MENU_CELL] ?: return false
if (!rect.contains(point)) return false
val popup = JBPopupFactory.getInstance().createActionGroupPopup(
@@ -858,7 +858,7 @@ private data class ActiveListHeightRow(
val trailing: String?,
val cells: List<ActiveListCell>,
val disabled: Boolean,
val deleting: Boolean,
val progress: String?,
)
private fun activeListHeightRow(item: ActiveListItem): ActiveListHeightRow {
@@ -873,7 +873,7 @@ private fun activeListHeightRow(item: ActiveListItem): ActiveListHeightRow {
item.trailing,
item.cells,
item.disabled,
item.deleting,
item.progress,
)
}
@@ -145,11 +145,6 @@ session.dock.move=Move to Worktree
session.dock.move.tooltip.empty=Move this conversation and your current local changes into a dedicated worktree for isolated follow-up work.
session.dock.move.tooltip.one=Move this conversation and 1 changed file into a dedicated worktree for isolated follow-up work.
session.dock.move.tooltip.other=Move this conversation and {0} changed files into a dedicated worktree for isolated follow-up work.
session.dock.progress.capturing=Capturing changes...
session.dock.progress.creating=Creating worktree...
session.dock.progress.transferring=Transferring changes...
session.dock.progress.forking=Starting session...
session.dock.move.failed.title=Failed to move to worktree
diff.editor.session.title=Session Changes
diff.editor.inline.title.named={0} ({1})
diff.editor.changedFiles.title=Changed files
@@ -416,7 +411,11 @@ worktree.configure.base.invalid.content=Select an existing base branch before cr
worktree.dialog.name.placeholder=Worktree name (optional)
worktree.dialog.prompt.placeholder=Describe what you want to start working on ({0} to create)
worktree.dialog.create=Create Worktree
worktree.dialog.creating=Creating…
worktree.progress.creating=Creating worktree
worktree.progress.capturing=Capturing changes…
worktree.progress.transferring=Transferring changes…
worktree.progress.starting=Starting session…
worktree.move.failed.title=Failed to move to worktree
worktree.import.pr.section=Pull Request
worktree.import.pr.failed.title=Couldn''t import pull request
worktree.import.branch.failed.title=Couldn''t import branch "{0}"
@@ -13,6 +13,7 @@ import ai.kilocode.client.agentManager.worktree.WorktreeStatusService
import ai.kilocode.client.agentManager.worktree.ensureWorktreeSessionEditorKind
import ai.kilocode.client.agentManager.worktree.worktreeSessionParams
import ai.kilocode.client.diff.KiloDiffEditorKind
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.SessionActivityKind
import ai.kilocode.client.testing.FakeWorktreeRpcApi
import ai.kilocode.client.testing.TestCoroutines
@@ -632,7 +633,7 @@ class AgentManagerPanelTest : BasePlatformTestCase() {
assertEquals("<html>Open #8<br>Click to open the pull request in your browser.</html>", row.metrics?.prTooltip)
}
fun `test worktree row hides badge while pending or deleting`() {
fun `test worktree row hides badge while in progress`() {
val path = "feature/y"
val activity = MutableStateFlow(mapOf(
"ses_1" to SessionActivityDto(path, SessionActivityKindDto.RUNNING),
@@ -647,7 +648,9 @@ class AgentManagerPanelTest : BasePlatformTestCase() {
val pending = row(panel, 0)
assertSame(WorktreeIcons.spinner, pending.icon)
assertEquals(KiloBundle.message("worktree.progress.creating"), pending.progress)
assertEquals(emptyList<ActiveListBadge>(), pending.badges)
assertNull(pending.metrics)
gate.complete(Unit)
flush()
}
@@ -7,11 +7,14 @@ import ai.kilocode.client.agentManager.worktree.PendingPrompt
import ai.kilocode.client.agentManager.worktree.PendingWorktreePrompt
import ai.kilocode.client.agentManager.worktree.WorktreeNameCache
import ai.kilocode.client.agentManager.worktree.WorktreeNames
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.SessionActivityKind
import ai.kilocode.client.testing.FakeWorktreeRpcApi
import ai.kilocode.client.testing.TestCoroutines
import ai.kilocode.client.testing.pumpEdt
import ai.kilocode.rpc.dto.CreateWorktreeResultDto
import ai.kilocode.rpc.dto.MoveProgressDto
import ai.kilocode.rpc.dto.MoveStage
import ai.kilocode.rpc.dto.RemoveWorktreeResultDto
import ai.kilocode.rpc.dto.RenameWorktreeResultDto
import ai.kilocode.rpc.dto.SessionActivityDto
@@ -161,13 +164,13 @@ class WorktreeControllerTest : BasePlatformTestCase() {
controller.remove(controller.model.getElementAt(0))
assertTrue(controller.isDeleting(item.id))
assertEquals(KiloBundle.message("common.deleting"), controller.progress(item.id))
assertEquals(1, controller.model.size)
gate.complete(Unit)
flush()
assertFalse(controller.isDeleting(item.id))
assertNull(controller.progress(item.id))
assertEquals(0, controller.model.size)
}
@@ -186,7 +189,7 @@ class WorktreeControllerTest : BasePlatformTestCase() {
// git rejected the removal, so the entry must remain instead of vanishing optimistically.
assertEquals(1, controller.model.size)
assertEquals("feature/x", controller.model.getElementAt(0).branch)
assertFalse(controller.isDeleting(item.id))
assertNull(controller.progress(item.id))
assertEquals(1, failures.size)
assertTrue(failures.first().locked)
}
@@ -321,6 +324,75 @@ class WorktreeControllerTest : BasePlatformTestCase() {
assertEquals("/wt/pr-7", controller.model.getElementAt(0).path)
}
fun `test move adds placeholder tracks progress and swaps to worktree`() {
val done = WorktreeDto("/wt/moved", "moved", "moved", "/wt/moved")
rpc.moveScript = listOf(
MoveProgressDto(MoveStage.CREATING),
MoveProgressDto(MoveStage.TRANSFERRING),
MoveProgressDto(MoveStage.FORKING),
MoveProgressDto(MoveStage.DONE, worktree = done, session = "ses_fork"),
)
val selected = mutableListOf<String>()
val aborts = mutableListOf<Pair<String, String>>()
val events = mutableListOf<Pair<String, Map<String, String>>>()
val controller = controller(abort = { id, dir -> aborts += id to dir }, telemetry = { name, props -> events += name to props })
controller.onSelect = { selected += it }
ApplicationManager.getApplication().invokeAndWait { controller.move("ses_source", "/repo") }
val temp = controller.model.getElementAt(0)
assertTrue(controller.isPending(temp.id))
assertEquals(KiloBundle.message("worktree.progress.capturing"), controller.progress(temp.id))
assertEquals(temp.id, selected.single())
flush()
assertEquals(listOf("ses_source" to "/repo"), aborts)
assertEquals("/repo", rpc.moves.single().first)
assertEquals("ses_source", rpc.moves.single().second)
assertEquals(done, controller.model.getElementAt(0))
assertNull(controller.progress(temp.id))
assertEquals("ses_fork", controller.takeSession(done.id))
assertNull(controller.takeSession(done.id))
assertEquals(done.id, selected.last())
assertTrue(events.any { it.first == "Continue in Worktree" && it.second["surface"] == "sidebar" })
}
fun `test move failure removes placeholder and reports last stage`() {
rpc.moveScript = listOf(
MoveProgressDto(MoveStage.CREATING),
MoveProgressDto(MoveStage.ERROR, error = "boom"),
)
val failures = mutableListOf<String?>()
val events = mutableListOf<Pair<String, Map<String, String>>>()
val controller = controller(telemetry = { name, props -> events += name to props })
controller.onMoveFailure = { failures += it }
ApplicationManager.getApplication().invokeAndWait { controller.move("ses_source", "/repo") }
flush()
assertEquals(0, controller.model.size)
assertEquals(listOf("boom"), failures)
assertTrue(events.any { it.first == "Continue in Worktree Failed" && it.second["stage"] == "CREATING" })
}
fun `test duplicate move for session is ignored while in flight`() {
val gate = CompletableDeferred<Unit>()
rpc.moveScript = listOf(MoveProgressDto(MoveStage.DONE, worktree = WorktreeDto("/wt/moved", "moved", "moved", "/wt/moved")))
val controller = controller(abort = { _, _ -> gate.await() })
ApplicationManager.getApplication().invokeAndWait {
controller.move("ses_source", "/repo")
controller.move("ses_source", "/repo")
}
flush()
assertEquals(1, controller.model.size)
assertEquals(0, rpc.moves.size)
gate.complete(Unit)
flush()
assertEquals(1, rpc.moves.size)
}
fun `test create stashes the prompt with its picked selection for the created worktree`() {
val controller = controller()
@@ -354,22 +426,22 @@ class WorktreeControllerTest : BasePlatformTestCase() {
fun `test worktree row icons show only while running or waiting`() {
assertSame(
WorktreeIcons.spinner,
WorktreeIcons.forRow(pending = true, kind = SessionActivityKind.RUNNING),
WorktreeIcons.forRow(busy = true, kind = SessionActivityKind.RUNNING),
)
assertSame(
WorktreeIcons.running,
WorktreeIcons.forRow(pending = false, kind = SessionActivityKind.RUNNING),
WorktreeIcons.forRow(busy = false, kind = SessionActivityKind.RUNNING),
)
assertSame(
SessionActivityKind.QUESTION.icon(),
WorktreeIcons.forRow(pending = false, kind = SessionActivityKind.QUESTION),
WorktreeIcons.forRow(busy = false, kind = SessionActivityKind.QUESTION),
)
assertSame(
SessionActivityKind.PLAN.icon(),
WorktreeIcons.forRow(pending = false, kind = SessionActivityKind.PLAN),
WorktreeIcons.forRow(busy = false, kind = SessionActivityKind.PLAN),
)
assertSame(WorktreeIcons.branch, WorktreeIcons.forRow(pending = false, kind = SessionActivityKind.ERROR))
assertSame(WorktreeIcons.branch, WorktreeIcons.forRow(pending = false, kind = null))
assertSame(WorktreeIcons.branch, WorktreeIcons.forRow(busy = false, kind = SessionActivityKind.ERROR))
assertSame(WorktreeIcons.branch, WorktreeIcons.forRow(busy = false, kind = null))
}
fun `test worktree icons load at the same size`() {
@@ -402,10 +474,10 @@ class WorktreeControllerTest : BasePlatformTestCase() {
val main = WorktreeDto("/repo", "repo", "main", "/repo", main = true)
val child = WorktreeDto("/repo/.kilo/worktrees/feature-x", "feature-x", "feature/x", "/repo/.kilo/worktrees/feature-x")
assertFalse(worktreeDeletable(null, pending = false))
assertFalse(worktreeDeletable(main, pending = false))
assertFalse(worktreeDeletable(child, pending = true))
assertTrue(worktreeDeletable(child, pending = false))
assertFalse(worktreeDeletable(null, busy = false))
assertFalse(worktreeDeletable(main, busy = false))
assertFalse(worktreeDeletable(child, busy = true))
assertTrue(worktreeDeletable(child, busy = false))
}
fun `test applyName updates the matching row so an adopted name shows live`() {
@@ -468,8 +540,11 @@ class WorktreeControllerTest : BasePlatformTestCase() {
assertEquals(listOf("/wt" to "Name", "/wt" to null), events)
}
private fun controller(activity: MutableStateFlow<Map<String, SessionActivityDto>> = MutableStateFlow(emptyMap())) =
WorktreeController(service, "/test", coroutines.scope, activity = activity)
private fun controller(
activity: MutableStateFlow<Map<String, SessionActivityDto>> = MutableStateFlow(emptyMap()),
abort: suspend (String, String) -> Unit = { _, _ -> },
telemetry: (String, Map<String, String>) -> Unit = { _, _ -> },
) = WorktreeController(service, "/test", coroutines.scope, activity = activity, abort = abort, telemetry = telemetry)
private fun flush() = coroutines.drain(::pump)
@@ -12,7 +12,7 @@ import java.awt.Color
class WorktreeIconsTest : BasePlatformTestCase() {
fun `test running session resolves to the animated spinner`() {
assertSame(WorktreeIcons.running, WorktreeIcons.forRow(pending = false, kind = SessionActivityKind.RUNNING))
assertSame(WorktreeIcons.running, WorktreeIcons.forRow(busy = false, kind = SessionActivityKind.RUNNING))
}
fun `test running icon is animated and sized to the row icon`() {
@@ -58,36 +58,36 @@ class WorktreeIconsTest : BasePlatformTestCase() {
return channels.max() - channels.min()
}
fun `test pending outranks running and uses the platform spinner`() {
assertSame(WorktreeIcons.spinner, WorktreeIcons.forRow(pending = true, kind = SessionActivityKind.RUNNING))
fun `test busy outranks running and uses the platform spinner`() {
assertSame(WorktreeIcons.spinner, WorktreeIcons.forRow(busy = true, kind = SessionActivityKind.RUNNING))
}
fun `test waiting kinds resolve to the attention glyph`() {
assertSame(
SessionActivityKind.QUESTION.icon(),
WorktreeIcons.forRow(pending = false, kind = SessionActivityKind.QUESTION),
WorktreeIcons.forRow(busy = false, kind = SessionActivityKind.QUESTION),
)
assertSame(SessionActivityKind.PLAN.icon(), WorktreeIcons.forRow(pending = false, kind = SessionActivityKind.PLAN))
assertSame(SessionActivityKind.PLAN.icon(), WorktreeIcons.forRow(busy = false, kind = SessionActivityKind.PLAN))
}
fun `test rows at rest show what the checkout is`() {
assertSame(WorktreeIcons.branch, WorktreeIcons.forRow(pending = false))
assertSame(WorktreeIcons.locked, WorktreeIcons.forRow(pending = false, locked = true))
assertSame(WorktreeIcons.local, WorktreeIcons.forRow(pending = false, current = true))
assertSame(WorktreeIcons.branch, WorktreeIcons.forRow(busy = false))
assertSame(WorktreeIcons.locked, WorktreeIcons.forRow(busy = false, locked = true))
assertSame(WorktreeIcons.local, WorktreeIcons.forRow(busy = false, current = true))
}
fun `test errored session falls back to the resting glyph`() {
assertSame(WorktreeIcons.branch, WorktreeIcons.forRow(pending = false, kind = SessionActivityKind.ERROR))
assertSame(WorktreeIcons.branch, WorktreeIcons.forRow(busy = false, kind = SessionActivityKind.ERROR))
assertSame(
WorktreeIcons.local,
WorktreeIcons.forRow(pending = false, kind = SessionActivityKind.ERROR, current = true),
WorktreeIcons.forRow(busy = false, kind = SessionActivityKind.ERROR, current = true),
)
}
fun `test activity outranks the resting glyph on the local row`() {
assertSame(
WorktreeIcons.running,
WorktreeIcons.forRow(pending = false, kind = SessionActivityKind.RUNNING, current = true),
WorktreeIcons.forRow(busy = false, kind = SessionActivityKind.RUNNING, current = true),
)
}
}
@@ -289,7 +289,7 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() {
val row = row("ses_1")
assertTrue(row.deleting)
assertEquals(KiloBundle.message("common.deleting"), row.progress)
}
fun `test pending new session groups under today`() {
@@ -190,6 +190,35 @@ class SessionUiLayoutTest : SessionUiTestBase() {
assertTrue(dock.isVisible)
}
fun `test dock move delegates to manager without progress state`() {
val calls = mutableListOf<Pair<String, String>>()
val owner = object : SessionManager {
override fun newSession() {}
override fun showHistory(back: (() -> Unit)?) {}
override fun openSession(ref: SessionRef) {}
override val supportsMoveToWorktree: Boolean get() = true
override fun moveToWorktree(sessionId: String, directory: String) {
calls += sessionId to directory
}
}
val worktree = FakeWorktreeRpcApi().apply {
branchResult = BranchStatusDto(branch = "main", availability = GhAvailability.OK)
}
ApplicationManager.getApplication()
.replaceService(KiloWorktreeService::class.java, KiloWorktreeService(scope, worktree), testRootDisposable)
rpc.history.addAll(history(1))
ui = newUi(id = "ses_test", manager = owner)
settle()
val dock = find<BranchDock>(ui)
assertTrue(dock.isVisible)
dock.triggerMove()
assertEquals(listOf("ses_test" to "/test"), calls)
assertTrue(dock.isVisible)
assertTrue(dock.moveEnabled())
}
fun `test dock branch changes refresh on finish and revert`() {
workspaceRpc.branchDiffs.clear()
workspaceRpc.branchDiffs.add(DiffFileDto("src/A.kt", 2, 1))
@@ -214,9 +214,9 @@ class HistoryControllerTest : BasePlatformTestCase() {
val row = localHistoryRows(listOf(item), snapshot) { true }[0]
assertTrue(row.deleting)
assertTrue(row.cells.isEmpty())
assertTrue(row.badges.isEmpty())
assertEquals(KiloBundle.message("common.deleting"), row.progress)
assertEquals(listOf(ACTIVE_LIST_RENAME_CELL, ACTIVE_LIST_DELETE_CELL), row.cells.map { it.id })
assertEquals(listOf(SessionActivityKind.RUNNING.label()), row.badges.map { it.text })
}
fun `test history panel sync updates running badges`() {
@@ -8,7 +8,6 @@ import ai.kilocode.rpc.dto.BranchStatusDto
import ai.kilocode.rpc.dto.DiffFileDto
import ai.kilocode.rpc.dto.GhAvailability
import ai.kilocode.rpc.dto.GhState
import ai.kilocode.rpc.dto.MoveStage
import ai.kilocode.rpc.dto.WorktreePrDto
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
@@ -136,21 +135,6 @@ class BranchDockTest : BasePlatformTestCase() {
assertFalse(update(ChatNewWorktreeAction(), dock).isVisible)
}
fun `test move progress keeps the row while session is busy`() {
val dock = dock()
edt {
dock.setBranch(BranchStatusDto(branch = "feature-x", worktree = false, availability = GhAvailability.OK))
dock.setHasMessages(true)
dock.setBusy(true)
dock.setMoveProgress(MoveStage.CREATING, null)
}
assertTrue(edt { dock.isVisible })
val p = update(ChatMoveToWorktreeAction(), dock)
assertTrue(p.isVisible)
assertFalse(p.isEnabled)
assertEquals(KiloBundle.message("session.dock.progress.creating"), p.text)
}
// ---- Move to Worktree action ----
fun `test move action visible with messages`() {
@@ -191,19 +175,6 @@ class BranchDockTest : BasePlatformTestCase() {
assertFalse(update(ChatMoveToWorktreeAction(), dock).isVisible)
}
fun `test move action shows spinner while moving`() {
val dock = dock()
edt {
dock.setBranch(BranchStatusDto(branch = "feature-x", worktree = false, availability = GhAvailability.OK))
dock.setHasMessages(true)
dock.setMoveProgress(MoveStage.CREATING, null)
}
val p = update(ChatMoveToWorktreeAction(), dock)
assertTrue(p.isVisible)
assertFalse(p.isEnabled)
assertEquals(KiloBundle.message("session.dock.progress.creating"), p.text)
}
fun `test move action invokes callback`() {
var moved = 0
val dock = edt { BranchDock(openDiff = {}, onMove = { moved++ }) }
@@ -214,6 +185,15 @@ class BranchDockTest : BasePlatformTestCase() {
assertEquals(1, moved)
}
fun `test move action hidden without a move host`() {
val dock = edt { BranchDock(openDiff = {}, onMove = null) }
edt {
dock.setBranch(BranchStatusDto(branch = "feature-x", worktree = false, availability = GhAvailability.OK))
dock.setHasMessages(true)
}
assertFalse(update(ChatMoveToWorktreeAction(), dock).isVisible)
}
// ---- New Worktree action ----
fun `test new worktree action visible when dock active`() {
@@ -212,6 +212,18 @@ class ActiveListSelectionTest : BasePlatformTestCase() {
assertEquals("created", view.selected()?.key)
}
fun `test progress row keeps cells in model but hides visible actions`() {
val item = object : ActiveListItem {
override val key = "busy"
override val title = "Busy"
override val progress = "Working..."
override val cells = listOf(ActiveListCell("open", "Open", primary = true))
}
assertEquals(listOf("open"), item.cells.map { it.id })
assertEquals(emptyList<ActiveListCell>(), activeListVisibleCells(item, active = true))
}
private fun view(onSelect: () -> Unit = {}): ActiveListView {
return ActiveListView("") { _, _ -> }.apply { this.onSelect = onSelect }
}
@@ -70,7 +70,6 @@ enum class MoveStage { CAPTURING, CREATING, TRANSFERRING, FORKING, DONE, ERROR }
@Serializable
data class MoveProgressDto(
val stage: MoveStage,
val detail: String? = null,
val error: String? = null,
val worktree: WorktreeDto? = null,
val session: String? = null,