diff --git a/.changeset/worktree-progress-agent-row.md b/.changeset/worktree-progress-agent-row.md new file mode 100644 index 0000000000..7b3e880cec --- /dev/null +++ b/.changeset/worktree-progress-agent-row.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Show worktree creation and move progress in the Agent Manager row instead of the chat dock. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt index 9cc0c3f609..761c0581b5 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt @@ -99,6 +99,7 @@ internal class KiloToolWindowSetupService( workspace.directory, cs, activity = project.service().activity, + abort = { id, dir -> project.service().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) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/ChatMoveToWorktreeAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/ChatMoveToWorktreeAction.kt index b368d73c0b..cc41f74f01 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/ChatMoveToWorktreeAction.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/ChatMoveToWorktreeAction.kt @@ -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") diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt index 1765da0e00..6bcd55ff26 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt @@ -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().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().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 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/MoveToWorktree.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/MoveToWorktree.kt deleted file mode 100644 index 55f79a3510..0000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/MoveToWorktree.kt +++ /dev/null @@ -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().abort(sessionId, directory) } - .onFailure { LOG.info("worktree move: abort failed (session may be idle): ${it.message}") } - val known = service().listBranches(directory).branches.toSet() - val branch = WorktreeNames.generate(known) - var stage = MoveStage.CAPTURING - val flow = service().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().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 - } - } -} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeController.kt index 8e0d4c31ee..1e1408342f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeController.kt @@ -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> = MutableStateFlow(emptyMap()), + private val abort: suspend (String, String) -> Unit = { _, _ -> }, private val telemetry: (String, Map) -> Unit = { event, props -> Telemetry.send(event, props) }, ) { val model = CollectionListModel() private val pending = LinkedHashMap() - private val deleting = Collections.synchronizedSet(LinkedHashSet()) + private val tasks = LinkedHashMap() + private val moves = LinkedHashSet() + private val sessions = LinkedHashMap() 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() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeIcons.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeIcons.kt index 9f9d789c4f..5fe84fbd5b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeIcons.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeIcons.kt @@ -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, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorKind.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorKind.kt index e5c2634055..569bbc7ae2 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorKind.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorKind.kt @@ -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 = 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().open(WorktreeSessionEditorKind.ID, worktreeSessionParams(worktree, session), focus) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanel.kt index 7693b836d8..d87cb64c02 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanel.kt @@ -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 - 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(" ") } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionManager.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionManager.kt index b3404a7106..f64c359163 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionManager.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionManager.kt @@ -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) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt index 6a7714e4b2..df20db5296 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index cdb6c724db..7641bddc70 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryRows.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryRows.kt index 3014e826ad..d9d4d5d1a8 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryRows.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryRows.kt @@ -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 - 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 - get() { - if (deleting) return emptyList() - return listOf(activeListRenameCell(), activeListDeleteCell()) - } + get() = listOf(activeListRenameCell(), activeListDeleteCell()) } internal data class CloudHistoryRow( diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/BranchDock.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/BranchDock.kt index e83c1547c6..20f37ce60e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/BranchDock.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/BranchDock.kt @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt index 51cecbdea4..bce6942747 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt @@ -109,7 +109,8 @@ internal interface ActiveListItem { val metrics: ActiveListMetrics? get() = null val cells: List 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 { 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() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt index 276767e2dc..9c603785a2 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt @@ -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 { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListView.kt index bb7cd3ae50..79f3dfa42d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListView.kt @@ -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, 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, ) } diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index 6b0f7e3873..0d41529c9e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -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}" diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt index 64580e720b..eef6909b3b 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt @@ -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("Open #8
Click to open the pull request in your browser.", 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(), pending.badges) + assertNull(pending.metrics) gate.complete(Unit) flush() } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeControllerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeControllerTest.kt index dffd2030e5..10d8e04d1a 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeControllerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeControllerTest.kt @@ -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() + val aborts = mutableListOf>() + val events = mutableListOf>>() + 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() + val events = mutableListOf>>() + 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() + 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> = MutableStateFlow(emptyMap())) = - WorktreeController(service, "/test", coroutines.scope, activity = activity) + private fun controller( + activity: MutableStateFlow> = MutableStateFlow(emptyMap()), + abort: suspend (String, String) -> Unit = { _, _ -> }, + telemetry: (String, Map) -> Unit = { _, _ -> }, + ) = WorktreeController(service, "/test", coroutines.scope, activity = activity, abort = abort, telemetry = telemetry) private fun flush() = coroutines.drain(::pump) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeIconsTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeIconsTest.kt index e0e04c05b0..72a98c1dde 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeIconsTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeIconsTest.kt @@ -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), ) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanelTest.kt index 5541e47ae4..9dbc014c80 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanelTest.kt @@ -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`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt index 7a179b738c..0253695371 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt @@ -190,6 +190,35 @@ class SessionUiLayoutTest : SessionUiTestBase() { assertTrue(dock.isVisible) } + fun `test dock move delegates to manager without progress state`() { + val calls = mutableListOf>() + 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(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)) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/history/HistoryControllerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/history/HistoryControllerTest.kt index 27c05f5d29..54fad9dc42 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/history/HistoryControllerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/history/HistoryControllerTest.kt @@ -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`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/BranchDockTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/BranchDockTest.kt index 08fdf2c0d1..a593b03336 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/BranchDockTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/BranchDockTest.kt @@ -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`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/list/ActiveListSelectionTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/list/ActiveListSelectionTest.kt index b2daca705f..c8e9b02ac1 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/list/ActiveListSelectionTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/list/ActiveListSelectionTest.kt @@ -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(), activeListVisibleCells(item, active = true)) + } + private fun view(onSelect: () -> Unit = {}): ActiveListView { return ActiveListView("") { _, _ -> }.apply { this.onSelect = onSelect } } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/WorktreeDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/WorktreeDto.kt index 848817f307..b3a98d6d58 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/WorktreeDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/WorktreeDto.kt @@ -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,