diff --git a/.changeset/agent-manager-history-routing-fix.md b/.changeset/agent-manager-history-routing-fix.md new file mode 100644 index 00000000000..a015ab31474 --- /dev/null +++ b/.changeset/agent-manager-history-routing-fix.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Fix project-scoped Agent Manager history activation and session placement. diff --git a/.changeset/agent-manager-history-switch-queue.md b/.changeset/agent-manager-history-switch-queue.md new file mode 100644 index 00000000000..9cfd32747aa --- /dev/null +++ b/.changeset/agent-manager-history-switch-queue.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Prevent overlapping Agent Manager history activations from leaving stale project-switch state. diff --git a/.changeset/explicit-agent-manager-provider.md b/.changeset/explicit-agent-manager-provider.md new file mode 100644 index 00000000000..31ed86c6abb --- /dev/null +++ b/.changeset/explicit-agent-manager-provider.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Allow Agent Manager task model overrides to specify an explicit provider when resolving model names. diff --git a/.changeset/fast-agent-manager-diffs.md b/.changeset/fast-agent-manager-diffs.md new file mode 100644 index 00000000000..8a51a2f6c98 --- /dev/null +++ b/.changeset/fast-agent-manager-diffs.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Load Agent Manager worktree diffs faster and keep warmed reviews visible when switching worktrees. diff --git a/.changeset/fix-background-promotion-edge-cases.md b/.changeset/fix-background-promotion-edge-cases.md new file mode 100644 index 00000000000..25df1e2f7c8 --- /dev/null +++ b/.changeset/fix-background-promotion-edge-cases.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Prevent stale subagent cards from showing background promotion and respect the background-subagent capability when promoting running tasks. diff --git a/.changeset/fix-cli-startup-followup.md b/.changeset/fix-cli-startup-followup.md new file mode 100644 index 00000000000..23d9ac25a06 --- /dev/null +++ b/.changeset/fix-cli-startup-followup.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Fix CLI help disposal and shell completion after startup optimization. diff --git a/.changeset/fix-sync-filter-lifecycle.md b/.changeset/fix-sync-filter-lifecycle.md new file mode 100644 index 00000000000..47964a30b15 --- /dev/null +++ b/.changeset/fix-sync-filter-lifecycle.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Prevent duplicate-event tracking from suppressing delayed sync events after reconnects or high event bursts. diff --git a/.changeset/isolated-reasoning-streams.md b/.changeset/isolated-reasoning-streams.md new file mode 100644 index 00000000000..10d21a53450 --- /dev/null +++ b/.changeset/isolated-reasoning-streams.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Prevent duplicate reasoning and response text while subagent sessions are open. diff --git a/.changeset/jetbrains-worktree-list-fixes.md b/.changeset/jetbrains-worktree-list-fixes.md new file mode 100644 index 00000000000..83b48d0a9cf --- /dev/null +++ b/.changeset/jetbrains-worktree-list-fixes.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Show worktree session titles in regular weight, keep the account switcher hidden when a new worktree starts with a prompt, add new worktrees at the top of the Agent Manager list, keep the running indicator on worktree rows when a stopped session is resumed, mark failed and waiting sessions on their worktree row and in session lists, keep the Agents tab notification dot up until every session that needs you is resolved, and keep session card popups inside the visible session view while pointing at their card. diff --git a/.changeset/jetbrains-worktree-pr-badge-clicks.md b/.changeset/jetbrains-worktree-pr-badge-clicks.md new file mode 100644 index 00000000000..30303e33ef0 --- /dev/null +++ b/.changeset/jetbrains-worktree-pr-badge-clicks.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Keep the PR badge in the JetBrains Agent Manager worktree list clickable and aligned with the rest of the row. diff --git a/.changeset/jetbrains-worktree-safety.md b/.changeset/jetbrains-worktree-safety.md new file mode 100644 index 00000000000..40e619f1b0c --- /dev/null +++ b/.changeset/jetbrains-worktree-safety.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Keep JetBrains Agent Manager worktrees in the main repository storage, prevent nested worktree deletion from removing child worktrees, and show a clear missing-folder error for deleted workspaces. diff --git a/.changeset/overlay-takes-hover.md b/.changeset/overlay-takes-hover.md new file mode 100644 index 00000000000..eca333f1903 --- /dev/null +++ b/.changeset/overlay-takes-hover.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Let session overlays such as the connection banner take the pointer over from the transcript beneath them, so a covered card no longer stays hovered or keeps its popup open behind the overlay. diff --git a/.changeset/plain-worktree-headers.md b/.changeset/plain-worktree-headers.md new file mode 100644 index 00000000000..ee268b38d64 --- /dev/null +++ b/.changeset/plain-worktree-headers.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Render Agent Manager worktree list labels in normal weight with quieter idle icons, tint monochrome row icons to the selection foreground while leaving status icons colored, and clear a deleted session's question/error status from the session list, worktree list, and tab attention dot. diff --git a/.changeset/quiet-manual-interruptions.md b/.changeset/quiet-manual-interruptions.md new file mode 100644 index 00000000000..07927ead3ee --- /dev/null +++ b/.changeset/quiet-manual-interruptions.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Stop manually aborted turns without briefly showing an interruption warning. diff --git a/.changeset/sidebar-scroll-preservation.md b/.changeset/sidebar-scroll-preservation.md new file mode 100644 index 00000000000..26c4cb0e3db --- /dev/null +++ b/.changeset/sidebar-scroll-preservation.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Preserve the Agent Manager sidebar scroll position when worktrees are deleted. diff --git a/.changeset/steady-location-keys.md b/.changeset/steady-location-keys.md new file mode 100644 index 00000000000..1607aba1a28 --- /dev/null +++ b/.changeset/steady-location-keys.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Keep file route location services on the same cache key as workspace-aware server routes. diff --git a/.changeset/terminal-replay-close-code.md b/.changeset/terminal-replay-close-code.md new file mode 100644 index 00000000000..2be3e869de9 --- /dev/null +++ b/.changeset/terminal-replay-close-code.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Use a browser-valid close code when Agent Manager terminal replay exceeds its buffer limit diff --git a/.gitignore b/.gitignore index 16f4034b7f6..86e7e67c9f9 100644 --- a/.gitignore +++ b/.gitignore @@ -59,6 +59,7 @@ tsconfig.tsbuildinfo .kilo/yarn.lock .kilo/node_modules .kilo/plans/*upstream-merge-report-*.md +**/.kilo/jetbrains.json .kilocode/.gitignore .kilocode/package.json .kilocode/package-lock.json diff --git a/packages/kilo-docs/pages/automate/agent-manager.md b/packages/kilo-docs/pages/automate/agent-manager.md index 70a4dc10dbb..8c4fa2c2332 100644 --- a/packages/kilo-docs/pages/automate/agent-manager.md +++ b/packages/kilo-docs/pages/automate/agent-manager.md @@ -214,7 +214,7 @@ The tool supports two modes: | `worktree` | Creates one Agent Manager git worktree and session per task | | `local` | Creates Agent Manager sessions in the current workspace without git worktree isolation | -Each request can include 1-20 tasks. Each task must include at least one of `prompt`, `name`, or `branchName`. Prompted tasks inherit the model and reasoning variant used by the chat turn that starts them. A task can override that selection with a `model` (by name, e.g. `Claude Opus 4.1`) when you explicitly request a different model, or with one of the current model's reasoning `variant` values when you request a different variant. Agent Manager resolves the provider for a model override, preferring the provider used by the current turn and falling back to the Kilo Gateway; a qualified `provider/model` ID is also accepted to force a specific provider. Prepared sessions without an initial prompt use the normal model defaults. Use `versions: true` only when the tasks are alternate versions of the same work to compare; otherwise, multiple tasks start as independent sessions. +Each request can include 1-20 tasks. Each task must include at least one of `prompt`, `name`, or `branchName`. Prompted tasks inherit the model and reasoning variant used by the chat turn that starts them. A task can override that selection with a `model` (by name, e.g. `Claude Opus 4.1`) when you explicitly request a different model, or with one of the current model's reasoning `variant` values when you request a different variant. Add `provider` beside `model` to force a model-name match to one of the listed provider IDs. Agent Manager resolves the provider for a model override when `provider` is omitted, preferring the provider used by the current turn and falling back to the Kilo Gateway; a qualified `provider/model` ID is also accepted. Prepared sessions without an initial prompt use the normal model defaults. Use `versions: true` only when the tasks are alternate versions of the same work to compare; otherwise, multiple tasks start as independent sessions. The companion `agent_manager_models` tool searches models and their supported reasoning variants on demand. Results are grouped by model name (with the offering providers listed for reference) and limited to 20 per call, so the full catalog is never added to the conversation context. diff --git a/packages/kilo-jetbrains/CHANGELOG.md b/packages/kilo-jetbrains/CHANGELOG.md index d33cdeb6e55..cfb492fa252 100644 --- a/packages/kilo-jetbrains/CHANGELOG.md +++ b/packages/kilo-jetbrains/CHANGELOG.md @@ -240,6 +240,25 @@ ## [Unreleased] +## [7.1.0-rc.4] - 2026-08-25 + +### Added +- Let Agent Manager tasks choose both provider and model so similarly named models across providers can be selected reliably. +- Show clearer missing-folder states for deleted or moved JetBrains worktrees. + +### Fixed +- Keep JetBrains Agent Manager worktrees anchored to the main repository's managed storage, preventing nested worktree data loss when the IDE is opened inside a linked worktree. +- Harden JetBrains worktree cleanup by pruning stale git metadata, hiding dead managed worktrees, refusing unmanaged paths, and blocking parent removal while nested worktrees are live. +- Surface failed JetBrains sessions consistently in worktree and session lists, and keep the Agents attention dot active until the problem is resolved. +- Restore running indicators when resuming sessions instead of leaving stale stopped or error state visible. +- Keep JetBrains session hover popups attached to the correct card, within the visible session area, and hidden behind blocking overlays. +- Keep worktree PR badges clickable after row reuse and layout changes. + +### Changed +- Improve Kilo Core startup speed for JetBrains and other clients, especially default TUI launch and short-lived commands. +- Put new, imported, or moved JetBrains Agent Manager worktrees at the top of the list and keep that ordering across reloads unless manually reordered. +- Make JetBrains Agent Manager rows visually quieter with regular-weight labels, subdued idle icons, and pruning of stale deleted-session status. + ## [7.1.0-rc.3] - 2026-08-24 ### Added diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendActivityManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendActivityManager.kt index d8bfd6d1d11..7cd72053882 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendActivityManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendActivityManager.kt @@ -89,7 +89,14 @@ class KiloBackendActivityManager( is ChatEventDto.Error -> event.sessionID?.let { errors.add(it) } is ChatEventDto.TurnOpen -> errors.remove(event.sessionID) is ChatEventDto.SessionIdle -> clear(event.sessionID) - is ChatEventDto.SessionStatusChanged -> if (event.status.type == "idle") clear(event.sessionID) + is ChatEventDto.SessionStatusChanged -> when (event.status.type) { + "idle" -> clear(event.sessionID) + // Work restarted, so whatever ended the previous turn (a Stop publishes + // MessageAbortedError) is stale. Not every resume path publishes a turn event, so + // busy has to clear the error itself. + "busy" -> errors.remove(event.sessionID) + else -> Unit + } else -> Unit } } @@ -115,8 +122,11 @@ class KiloBackendActivityManager( if (pending.values.any { it }) return SessionActivityKindDto.PLAN return SessionActivityKindDto.QUESTION } - if (id in errors) return SessionActivityKindDto.ERROR + // Live work outranks a past error: the status stream and the chat events are separate + // collectors, so a resumed session can go busy before the event that clears its error + // arrives, and the row must keep spinning instead of resting on the stale error. if (busy) return SessionActivityKindDto.RUNNING + if (id in errors) return SessionActivityKindDto.ERROR return null } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt index 4abe1975d67..15bfc066427 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt @@ -508,6 +508,10 @@ class KiloWorkspaceRpcApiImpl internal constructor( status = KiloWorkspaceStatusDto.UNSUPPORTED, error = state.reason, ) + is KiloWorkspaceState.Missing -> KiloWorkspaceStateDto( + status = KiloWorkspaceStatusDto.MISSING, + error = state.path, + ) is KiloWorkspaceState.Error -> KiloWorkspaceStateDto( status = KiloWorkspaceStatusDto.ERROR, error = state.message, diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt index 2ef869b45ae..50b2b8326dc 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt @@ -84,9 +84,10 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { val res = runGit(base, "worktree", "list", "--porcelain") if (!res.ok) return@withContext WorktreeListDto() val items = managedWorktrees(parseWorktreeList(res.stdout)) - val store = worktreeNameStore(items) - val state = store?.let { syncWorktreeState(it, worktreePaths(items)) } ?: WorktreeState() - val named = overlayWorktreeNames(items, state.names) + val alive = items.filter { it.main || Files.isDirectory(Path.of(it.path)) } + val store = worktreeNameStore(alive) + val state = store?.let { syncWorktreeState(it, worktreePaths(alive)) } ?: WorktreeState() + val named = overlayWorktreeNames(alive, state.names) WorktreeListDto(orderWorktrees(named, state.worktreeOrder)) } @@ -277,6 +278,14 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { return Path.of(lines[0]).normalize() != Path.of(lines[1]).normalize() } + /** Main working tree for the repo containing [base]; falls back to [base] when git fails. */ + private fun mainWorktree(base: Path): Path { + val res = runGit(base, "worktree", "list", "--porcelain") + if (!res.ok) return base + val main = parseWorktreeList(res.stdout).firstOrNull { it.main } ?: return base + return Path.of(main.path).normalize() + } + override suspend fun create(directory: String, request: CreateWorktreeRequestDto): CreateWorktreeResultDto = withContext(Dispatchers.IO) { val base = Path.of(directory).normalize() @@ -313,7 +322,12 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { /** Runs `git worktree add` under `/.kilo/worktrees/` and records list bookkeeping. */ private fun addWorktree(base: Path, branch: String, existing: Boolean, baseRef: String?): CreateWorktreeResultDto { - val dir = base.resolve(".kilo").resolve("worktrees").resolve(branch.replace('/', '-')) + val root = mainWorktree(base) + val storage = root.resolve(".kilo").resolve("worktrees").normalize() + val parts = branch.split('/') + if (parts.any { it.isBlank() || it == "." || it == ".." }) return CreateWorktreeResultDto(error = "Invalid branch name") + val dir = storage.resolve(branch.replace('/', '-')).normalize() + if (dir.parent != storage) return CreateWorktreeResultDto(error = "Invalid branch name") Files.createDirectories(dir.parent) val args = buildList { addAll(listOf("worktree", "add")) @@ -328,7 +342,7 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { } } LOG.info("worktree add requested: branch=$branch existing=$existing base=${baseRef ?: "(current)"} dir=$dir") - val res = runGit(base, *args.toTypedArray()) + val res = add(base, args) if (!res.ok) { LOG.warn("worktree add failed: branch=$branch exit=${res.exit} stderr=${res.stderr.trim()}") return CreateWorktreeResultDto(error = res.stderr.ifBlank { "git worktree add failed" }) @@ -339,7 +353,7 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { val items = if (list.ok) managedWorktrees(parseWorktreeList(list.stdout)) else emptyList() val store = worktreeNameStore(items) ?: base.resolve(".kilo").resolve(WORKTREE_NAMES_FILE) val paths = worktreePaths(items).ifEmpty { listOf(path) } - appendWorktreeOrder(store, path, paths) + prependWorktreeOrder(store, path, paths) return CreateWorktreeResultDto(worktree = WorktreeDto(path, dir.fileName.toString(), branch, path)) } @@ -348,15 +362,42 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { val base = Path.of(directory).normalize() LOG.info("worktree remove requested: path=$path branch=${branch ?: "(none)"} force=$force base=$base") val list = runGit(base, "worktree", "list", "--porcelain") - val store = (if (list.ok) worktreeNameStore(managedWorktrees(parseWorktreeList(list.stdout))) else null) - ?: base.resolve(".kilo").resolve(WORKTREE_NAMES_FILE) + if (!list.ok) return@withContext RemoveWorktreeResultDto(error = list.stderr.ifBlank { "git worktree list failed" }) + val all = parseWorktreeList(list.stdout) + val items = managedWorktrees(all) + val main = all.firstOrNull { it.main } + val storage = main?.let { Path.of(it.path).normalize().resolve(".kilo").resolve("worktrees").normalize() } + val target = all.firstOrNull { + val item = Path.of(it.path).normalize() + !it.main && samePath(it.path, path) && item.parent == storage + } + ?: return@withContext RemoveWorktreeResultDto(error = "Refusing to remove unmanaged worktree: $path") + // Compare canonical (symlink-resolved) paths: on macOS the temp/repo root is a symlink + // (/var -> /private/var), so a raw startsWith against normalized porcelain paths would miss + // a live child and let `git worktree remove --force` delete it recursively. + val root = realPath(path) + val nested = all.filter { + !it.prunable && Files.isDirectory(Path.of(it.path)) && !samePath(it.path, path) && realPath(it.path).startsWith(root) + } + if (nested.isNotEmpty()) { + val names = nested.joinToString("\n") { it.path } + return@withContext RemoveWorktreeResultDto(error = "Delete nested worktrees first:\n$names") + } + val store = worktreeNameStore(items) ?: base.resolve(".kilo").resolve(WORKTREE_NAMES_FILE) // Force means the user accepted removing a locked worktree; unlock first so the plain // remove succeeds. Unlock fails harmlessly when the tree isn't actually locked. if (force) { - val unlock = runGit(base, "worktree", "unlock", path) + val unlock = runGit(base, "worktree", "unlock", target.path) if (!unlock.ok) LOG.info("worktree unlock skipped: path=$path exit=${unlock.exit} stderr=${unlock.stderr.trim()}") } - val res = runGit(base, "worktree", "remove", "--force", path) + // Only skip git's own removal when the checkout directory is actually gone. Git also flags a + // worktree prunable when its admin metadata is stale while the files remain; those must still + // be deleted so a later create of the same slug is not blocked by leftovers. + val res = if (!Files.isDirectory(Path.of(target.path))) { + GitResult(0, "", "") + } else { + runGit(base, "worktree", "remove", "--force", target.path) + } if (!res.ok) { val locked = res.stderr.contains("locked working tree", ignoreCase = true) LOG.warn("worktree remove failed: path=$path locked=$locked exit=${res.exit} stderr=${res.stderr.trim()}") @@ -371,7 +412,11 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { if (!del.ok) LOG.warn("worktree branch delete failed: branch=$it exit=${del.exit} stderr=${del.stderr.trim()}") } LOG.info("worktree removed: path=$path branch=${branch ?: "(none)"}") - removeWorktreeState(store, path) + removeWorktreeState(store, target.path) + val prune = runGit(base, "worktree", "prune") + if (!prune.ok) LOG.warn("worktree prune failed: exit=${prune.exit} stderr=${prune.stderr.trim()}") + runCatching { service().workspaces.remove(target.path) } + .onFailure { err -> LOG.info("workspace cache eviction skipped: path=${target.path} message=${err.message}") } RemoveWorktreeResultDto(ok = true) } @@ -472,6 +517,20 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { } } + private fun add(base: Path, args: List): GitResult { + val first = runGit(base, *args.toTypedArray()) + if (first.ok || !stale(first.stderr)) return first + val prune = runGit(base, "worktree", "prune") + if (!prune.ok) LOG.warn("worktree prune before retry failed: exit=${prune.exit} stderr=${prune.stderr.trim()}") + return runGit(base, *args.toTypedArray()) + } + + private fun stale(text: String): Boolean { + return text.contains("is already checked out", ignoreCase = true) || + text.contains("already used by worktree", ignoreCase = true) || + text.contains("missing but already registered worktree", ignoreCase = true) + } + private suspend fun parallel(items: List, block: suspend (T) -> R): List = coroutineScope { val sem = Semaphore(4) items.map { item -> async { sem.withPermit { block(item) } } }.map { it.await() } @@ -627,16 +686,18 @@ internal fun parseWorktreeList(raw: String): List { var branch = "(detached)" var locked = false var lockReason: String? = null + var prunable = false var first = true fun flush() { val p = path ?: return val name = p.substringAfterLast('/').ifBlank { p } - out.add(WorktreeDto(p, name, branch, p, main = first, locked = locked, lockReason = lockReason)) + out.add(WorktreeDto(p, name, branch, p, main = first, locked = locked, lockReason = lockReason, prunable = prunable)) first = false path = null branch = "(detached)" locked = false lockReason = null + prunable = false } for (line in raw.lines()) { when { @@ -646,6 +707,7 @@ internal fun parseWorktreeList(raw: String): List { locked = true lockReason = line.removePrefix("locked").trim().takeIf { it.isNotEmpty() } } + line == "prunable" || line.startsWith("prunable ") -> prunable = true line.isBlank() -> flush() } } @@ -659,8 +721,9 @@ internal fun managedWorktrees(items: List): List { val storage = root.resolve(".kilo").resolve("worktrees").normalize() return items.filter { item -> if (item.main) return@filter true + if (item.prunable) return@filter false val path = Path.of(item.path).normalize() - path.startsWith(storage) && path != storage + path.parent == storage } } @@ -738,13 +801,12 @@ private fun syncWorktreeState(file: Path, paths: List): WorktreeState { return next } -private fun appendWorktreeOrder(file: Path, path: String, paths: List) { +private fun prependWorktreeOrder(file: Path, path: String, paths: List) { val state = readWorktreeState(file) val set = paths.toSet() - val order = state.worktreeOrder.filter { it in set && !samePath(it, path) } + - paths.filter { it !in state.worktreeOrder && !samePath(it, path) } + - path - writeWorktreeState(file, state.copy(worktreeOrder = order.distinct())) + val rest = state.worktreeOrder.filter { it in set && !samePath(it, path) } + + paths.filter { it !in state.worktreeOrder && !samePath(it, path) } + writeWorktreeState(file, state.copy(worktreeOrder = (listOf(path) + rest).distinct())) } private fun removeWorktreeState(file: Path, path: String) { diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt index cd700fc2094..c35d1e17a03 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt @@ -28,6 +28,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request +import java.nio.file.Files +import java.nio.file.Path import java.util.concurrent.atomic.AtomicReference /** @@ -77,6 +79,11 @@ class KiloBackendWorkspace( _state.value = KiloWorkspaceState.Unsupported(reason) return@launch } + if (!Files.isDirectory(Path.of(directory))) { + log.info("Workspace directory is missing: $directory") + _state.value = KiloWorkspaceState.Missing(directory) + return@launch + } val progress = AtomicReference(KiloWorkspaceLoadProgress()) _state.value = KiloWorkspaceState.Loading(progress.get()) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceManager.kt index 93ec913349d..d7f5da9b112 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceManager.kt @@ -8,6 +8,8 @@ import ai.kilocode.jetbrains.api.client.DefaultApi import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.SharedFlow import okhttp3.OkHttpClient +import java.nio.file.Files +import java.nio.file.Path import java.util.concurrent.ConcurrentHashMap /** @@ -74,8 +76,26 @@ class KiloBackendWorkspaceManager( } } - /** Remove a workspace (e.g. when a worktree is deleted). */ + /** + * Remove any cached workspace whose directory resolves to the same real path as [dir]. + * Callers pass git porcelain paths, while workspaces are often keyed by the resolved + * (`toRealPath`) path or the IDE base path, so an exact-string match would miss the entry + * and leave a deleted worktree cached as Ready — still producing backend errors. + */ fun remove(dir: String) { - workspaces.remove(dir)?.stop() + val target = canonical(dir) + workspaces.keys.filter { canonical(it) == target }.forEach { key -> + log.info("Removing cached workspace for $key") + workspaces.remove(key)?.stop() + } + } + + /** Resolve symlinks on the parent so `/var/...` and `/private/var/...` compare equal even after the leaf is deleted. */ + private fun canonical(dir: String): String { + val path = Path.of(dir).normalize() + val parent = path.parent ?: return path.toString() + val name = path.fileName ?: return path.toString() + val root = runCatching { if (Files.exists(parent)) parent.toRealPath() else parent }.getOrDefault(parent) + return root.resolve(name).toString() } } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloWorkspaceState.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloWorkspaceState.kt index c9a543b1efc..239ba178c80 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloWorkspaceState.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloWorkspaceState.kt @@ -20,6 +20,7 @@ sealed class KiloWorkspaceState { val skills: List, ) : KiloWorkspaceState() data class Unsupported(val reason: String) : KiloWorkspaceState() + data class Missing(val path: String) : KiloWorkspaceState() data class Error(val message: String, val errors: List = emptyList()) : KiloWorkspaceState() } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendActivityManagerTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendActivityManagerTest.kt index 1a6d1f9f4ef..a06aa09b640 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendActivityManagerTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendActivityManagerTest.kt @@ -102,10 +102,8 @@ class KiloBackendActivityManagerTest { statuses.value = mapOf("ses_1" to SessionStatusDto("busy")) start() + // Turn ends on an error: the session goes idle but the error must stay visible. events.emit(ChatEventDto.Error("ses_1")) - await("ses_1", SessionActivityKindDto.ERROR) - - // Turn ends: session goes idle but the error must stay visible. statuses.value = mapOf("ses_1" to SessionStatusDto("idle")) events.emit(ChatEventDto.SessionIdle("ses_1")) await("ses_1", SessionActivityKindDto.ERROR) @@ -116,6 +114,35 @@ class KiloBackendActivityManagerTest { assertFalse("ses_1" in manager.activity.value) } + @Test + fun `busy outranks a pending error so a resumed session runs`() = runBlocking { + directories["ses_1"] = "/repo/wt" + start() + + // A Stop leaves the session errored and idle. + events.emit(ChatEventDto.Error("ses_1")) + await("ses_1", SessionActivityKindDto.ERROR) + + // Resumed: busy arrives before anything clears the error. + statuses.value = mapOf("ses_1" to SessionStatusDto("busy")) + + await("ses_1", SessionActivityKindDto.RUNNING) + } + + @Test + fun `busy status event clears a pending error`() = runBlocking { + directories["ses_1"] = "/repo/wt" + start() + + events.emit(ChatEventDto.Error("ses_1")) + await("ses_1", SessionActivityKindDto.ERROR) + + events.emit(ChatEventDto.SessionStatusChanged("ses_1", SessionStatusDto("busy"))) + + withTimeout(5_000) { manager.activity.first { "ses_1" !in it } } + assertFalse("ses_1" in manager.activity.value) + } + @Test fun `global error without session is ignored`() = runBlocking { directories["ses_1"] = "/repo/wt" diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt index fd7de316a9b..fd30a3732d2 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt @@ -81,6 +81,26 @@ class KiloWorktreeRpcApiImplTest { assertEquals("Air Agent worktree", list[1].lockReason) } + @Test + fun `parseWorktreeList captures the prunable flag`() { + val raw = """ + worktree /repo + HEAD 1111111111111111111111111111111111111111 + branch refs/heads/main + + worktree /repo/.kilo/worktrees/hyper-video + HEAD 2222222222222222222222222222222222222222 + branch refs/heads/hyper-video + prunable gitdir file points to non-existent location + + """.trimIndent() + + val list = parseWorktreeList(raw) + + assertFalse(list[0].prunable, "main tree is not prunable") + assertTrue(list[1].prunable, "second tree should be flagged prunable") + } + @Test fun `managedWorktrees keeps only agent manager worktrees`() { val raw = """ @@ -125,6 +145,33 @@ class KiloWorktreeRpcApiImplTest { assertEquals(listOf("/repo"), list.map { it.path }) } + @Test + fun `managedWorktrees rejects nested and prunable worktrees`() { + val raw = """ + worktree /repo + HEAD 1111111111111111111111111111111111111111 + branch refs/heads/main + + worktree /repo/.kilo/worktrees/feature-x + HEAD 2222222222222222222222222222222222222222 + branch refs/heads/feature/x + + worktree /repo/.kilo/worktrees/feature-x/.kilo/worktrees/nested + HEAD 3333333333333333333333333333333333333333 + branch refs/heads/nested + + worktree /repo/.kilo/worktrees/dead + HEAD 4444444444444444444444444444444444444444 + branch refs/heads/dead + prunable gitdir file points to non-existent location + + """.trimIndent() + + val list = managedWorktrees(parseWorktreeList(raw)) + + assertEquals(listOf("/repo", "/repo/.kilo/worktrees/feature-x"), list.map { it.path }) + } + @Test fun `classifyGhError detects missing and unauthorized gh states`() { assertEquals(GhAvailability.UNAUTH, classifyGhError("You are not logged into any GitHub hosts. Run gh auth login to authenticate.")) @@ -234,15 +281,56 @@ class KiloWorktreeRpcApiImplTest { } @Test - fun `create records order so reload keeps creation order`() = runBlocking { + fun `create from inside linked worktree uses main worktree storage`() = runBlocking { + initRepo() + val first = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("feature/x")).worktree) + + val result = api.create(first.path, CreateWorktreeRequestDto("feature/y")) + val created = assertNotNull(result.worktree, "create failed: ${result.error}") + + assertEquals(repo.resolve(".kilo").resolve("worktrees").resolve("feature-y").toRealPath().toString(), created.path) + assertFalse( + Files.exists(Path.of(first.path).resolve(".kilo").resolve("worktrees").resolve("feature-y")), + "creating from a linked worktree must not nest storage inside it", + ) + } + + @Test + fun `create rejects a branch slug that escapes storage`() = runBlocking { + initRepo() + + val result = api.create(repo.toString(), CreateWorktreeRequestDto("../escape")) + + assertNull(result.worktree) + assertEquals("Invalid branch name", result.error) + assertFalse(Files.exists(repo.resolve(".kilo").resolve("escape"))) + } + + @Test + fun `create succeeds after pruning a deleted checked out branch`() = runBlocking { + initRepo() + val first = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("feature/x")).worktree) + delete(Path.of(first.path)) + + val result = api.create(repo.toString(), CreateWorktreeRequestDto("feature/x", existingBranch = true)) + + val created = assertNotNull(result.worktree, "create should prune stale metadata and retry: ${result.error}") + assertTrue(Files.isDirectory(Path.of(created.path))) + } + + @Test + fun `create records newest worktree first so reload keeps it on top`() = runBlocking { initRepo() val first = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("zebra")).worktree) val second = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("alpha")).worktree) val listed = api.list(repo.toString()).worktrees.filter { !it.main } - assertEquals(listOf(first.path, second.path), listed.map { it.path }) - assertEquals(listOf(first.path, second.path), readWorktreeState(repo.resolve(".kilo").resolve("jetbrains.json")).worktreeOrder) + assertEquals(listOf(second.path, first.path), listed.map { it.path }) + assertEquals( + listOf(second.path, first.path), + readWorktreeState(repo.resolve(".kilo").resolve("jetbrains.json")).worktreeOrder, + ) } @Test @@ -360,6 +448,84 @@ class KiloWorktreeRpcApiImplTest { assertTrue(result.error != null, "failure should carry an error message") } + @Test + fun `remove refuses a path outside managed storage`() = runBlocking { + initRepo() + val outside = repo.resolve("outside") + Files.createDirectories(outside) + + val result = api.remove(repo.toString(), outside.toString(), null) + + assertFalse(result.ok) + assertTrue(result.error?.contains("Refusing") == true) + assertTrue(Files.isDirectory(outside), "unmanaged directory must not be touched") + } + + @Test + fun `remove refuses a worktree containing a live nested worktree`() = runBlocking { + initRepo() + val parent = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("feature/x")).worktree) + val nested = assertNotNull(api.create(parent.path, CreateWorktreeRequestDto("feature/y")).worktree) + val old = Path.of(parent.path).resolve(".kilo").resolve("worktrees").resolve("nested") + Files.createDirectories(old.parent) + git(parent.path, "worktree", "move", nested.path, old.toString()) + + val result = api.remove(repo.toString(), parent.path, parent.branch) + + assertFalse(result.ok) + assertTrue(result.error?.contains(old.toString()) == true, "error should name the blocker: ${result.error}") + assertTrue(Files.isDirectory(Path.of(parent.path))) + assertTrue(Files.isDirectory(old)) + } + + @Test + fun `remove succeeds when nested worktree directory is already gone`() = runBlocking { + initRepo() + val parent = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("feature/x")).worktree) + val nested = assertNotNull(api.create(parent.path, CreateWorktreeRequestDto("feature/y")).worktree) + val old = Path.of(parent.path).resolve(".kilo").resolve("worktrees").resolve("nested") + Files.createDirectories(old.parent) + git(parent.path, "worktree", "move", nested.path, old.toString()) + delete(old) + + val result = api.remove(repo.toString(), parent.path, parent.branch) + + assertTrue(result.ok, "remove should succeed despite dead nested metadata: ${result.error}") + assertFalse(Files.exists(Path.of(parent.path))) + } + + @Test + fun `remove prunes dangling metadata on success`() = runBlocking { + initRepo() + val dead = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("dead")).worktree) + delete(Path.of(dead.path)) + val live = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("live")).worktree) + + val result = api.remove(repo.toString(), live.path, live.branch) + + assertTrue(result.ok, "remove should succeed: ${result.error}") + val out = output(repo, "worktree", "list", "--porcelain") + assertFalse(out.contains(dead.path), "remove should prune unrelated dangling worktree metadata") + } + + @Test + fun `list drops missing worktrees and reconciles stored state`() = runBlocking { + initRepo() + val live = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("live")).worktree) + val dead = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("dead")).worktree) + assertNotNull(api.rename(repo.toString(), live.path, "Live").worktree) + assertNotNull(api.rename(repo.toString(), dead.path, "Dead").worktree) + delete(Path.of(dead.path)) + + val listed = api.list(repo.toString()).worktrees + + assertTrue(listed.any { it.path == live.path }) + assertFalse(listed.any { it.path == dead.path }) + val state = readWorktreeState(repo.resolve(".kilo").resolve("jetbrains.json")) + assertEquals(mapOf(live.path to "Live"), state.names) + assertEquals(listOf(live.path), state.worktreeOrder) + } + @Test fun `listBranches returns local branches and the current one`() = runBlocking { initRepo() @@ -593,6 +759,17 @@ class KiloWorktreeRpcApiImplTest { assertEquals(0, out.exitCode, "git ${args.joinToString(" ")} failed: ${out.stderr}") } + private fun git(dir: String, vararg args: String) { + git(Path.of(dir), *args) + } + + private fun output(dir: Path, vararg args: String): String { + val cmd = GeneralCommandLine(listOf("git") + args).withWorkDirectory(dir.toFile()) + val out = CapturingProcessHandler(cmd).runProcess(30_000) + assertEquals(0, out.exitCode, "git ${args.joinToString(" ")} failed: ${out.stderr}") + return out.stdout + } + private fun delete(dir: Path) { if (!Files.exists(dir)) return Files.walk(dir).use { paths -> diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt index b3358b033bd..93227e52548 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt @@ -25,6 +25,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeoutOrNull +import java.nio.file.Files +import java.nio.file.Path import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals @@ -39,6 +41,8 @@ class KiloBackendWorkspaceTest { private val log = TestLog() private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val apps = mutableListOf() + private val root: Path = Files.createTempDirectory("kilo-backend-workspace") + private val project: Path = Files.createDirectories(root.resolve("project")) @AfterTest fun tearDown() { @@ -48,6 +52,7 @@ class KiloBackendWorkspaceTest { scope.cancel() mock.close() withTimeout(10_000) { scope.coroutineContext[Job]?.join() } + delete(root) } } @@ -69,9 +74,11 @@ class KiloBackendWorkspaceTest { private suspend fun ready(app: KiloBackendAppService): KiloBackendWorkspace { connect(app) - return app.workspaces.get("/test/project") + return app.workspaces.get(project.toString()) } + private fun dir(name: String): String = Files.createDirectories(root.resolve(name)).toString() + private suspend fun loaded(ws: KiloBackendWorkspace) { withTimeout(15_000) { ws.state.first { it is KiloWorkspaceState.Ready } @@ -112,8 +119,9 @@ class KiloBackendWorkspaceTest { val app = setup() connect(app) - val ws1 = app.workspaces.get("/test") - val ws2 = app.workspaces.get("/test") + val path = dir("same") + val ws1 = app.workspaces.get(path) + val ws2 = app.workspaces.get(path) // LLM note: get() starts background loading; settle it so teardown is not racing active HTTP calls in CI. loaded(ws1) assertTrue(ws1 === ws2) @@ -124,14 +132,16 @@ class KiloBackendWorkspaceTest { val app = setup() connect(app) - val ws1 = app.workspaces.get("/project-a") - val ws2 = app.workspaces.get("/project-b") + val first = dir("project-a") + val second = dir("project-b") + val ws1 = app.workspaces.get(first) + val ws2 = app.workspaces.get(second) // LLM note: get() starts background loading; settle both loads before the scope-cancelling teardown. loaded(ws1) loaded(ws2) assertTrue(ws1 !== ws2) - assertEquals("/project-a", ws1.directory) - assertEquals("/project-b", ws2.directory) + assertEquals(first, ws1.directory) + assertEquals(second, ws2.directory) } @Test @@ -150,7 +160,7 @@ class KiloBackendWorkspaceTest { // Manager should throw since app is disconnected assertFailsWith { - app.workspaces.get("/test/project") + app.workspaces.get(project.toString()) } } @@ -187,7 +197,7 @@ class KiloBackendWorkspaceTest { connect(app) // get() creates workspace and starts loading immediately - val ws = app.workspaces.get("/test") + val ws = app.workspaces.get(dir("plain")) withTimeout(15_000) { ws.state.first { it is KiloWorkspaceState.Ready } @@ -211,7 +221,7 @@ class KiloBackendWorkspaceTest { val err = ws.state.value as KiloWorkspaceState.Error assertTrue(err.message.contains("providers")) assertTrue(err.errors.any { it.resource == "providers" }) - assertTrue(log.messages.any { it.contains("Workspace error [/test/project]: Failed to load:") && it.contains("providers") }) + assertTrue(log.messages.any { it.contains("Workspace error [${project}]: Failed to load:") && it.contains("providers") }) } @Test @@ -298,6 +308,26 @@ class KiloBackendWorkspaceTest { assertEquals(0, mock.requestCount("/agent")) } + @Test + fun `missing directory transitions to Missing without fetching workspace data`() = runBlocking { + val app = setup() + connect(app) + mock.resetCounts() + val dir = Files.createTempDirectory("kilo-missing-workspace") + Files.delete(dir) + val ws = app.workspaces.get(dir.toString()) + + val state = withTimeout(15_000) { + ws.state.first { it is KiloWorkspaceState.Missing } + } as KiloWorkspaceState.Missing + + assertEquals(dir.toString(), state.path) + assertEquals(0, mock.requestCount("/agent")) + assertEquals(0, mock.requestCount("/provider")) + assertEquals(0, mock.requestCount("/command")) + assertEquals(0, mock.requestCount("/skill")) + } + @Test fun `commands failure transitions to Error`() = runBlocking { mock.commandsStatus = 500 @@ -446,7 +476,7 @@ class KiloBackendWorkspaceTest { @Test fun `workspace exposes sessions for its directory`() = runBlocking { mock.sessions = """[ - {"id":"ses_1","slug":"s","projectID":"p","directory":"/test/project","title":"T","version":"1","time":{"created":1,"updated":1}} + {"id":"ses_1","slug":"s","projectID":"p","directory":"${project}","title":"T","version":"1","time":{"created":1,"updated":1}} ]""" val app = setup() val ws = ready(app) @@ -460,7 +490,7 @@ class KiloBackendWorkspaceTest { @Test fun `workspace maps missing session timestamps to zero`() = runBlocking { mock.sessions = """[ - {"id":"ses_1","slug":"s","projectID":"p","directory":"/test/project","title":"T","version":"1","time":{"created":null,"updated":null}} + {"id":"ses_1","slug":"s","projectID":"p","directory":"${project}","title":"T","version":"1","time":{"created":null,"updated":null}} ]""" val app = setup() val ws = ready(app) @@ -473,14 +503,14 @@ class KiloBackendWorkspaceTest { @Test fun `workspace creates session in its directory`() = runBlocking { - mock.sessionCreate = """{"id":"ses_new","slug":"n","projectID":"p","directory":"/test/project","title":"New","version":"1","time":{"created":1,"updated":1}}""" + mock.sessionCreate = """{"id":"ses_new","slug":"n","projectID":"p","directory":"${project}","title":"New","version":"1","time":{"created":1,"updated":1}}""" val app = setup() val ws = ready(app) loaded(ws) val session = ws.createSession() assertEquals("ses_new", session.id) - assertEquals("/test/project", session.directory) + assertEquals(project.toString(), session.directory) } // ------ Concurrency tests ------ @@ -498,7 +528,7 @@ class KiloBackendWorkspaceTest { try { val results = (1..10).map { async(Dispatchers.Default) { - manager.get("/same/dir") + manager.get(dir("same-concurrent")) } }.awaitAll() @@ -572,7 +602,7 @@ class KiloBackendWorkspaceTest { ) withTimeout(15_000) { reload.await() } - val ws = app.workspaces.get("/test/project") + val ws = app.workspaces.get(project.toString()) assertTrue(ws !== initial) val state = withTimeout(15_000) { ws.state.first { @@ -646,4 +676,11 @@ class KiloBackendWorkspaceTest { {"name":"test-skill","description":"A test skill","location":"file:///test","content":"# Test"} ]""".trimIndent() } + + private fun delete(dir: Path) { + if (!Files.exists(dir)) return + Files.walk(dir).use { paths -> + paths.sorted(Comparator.reverseOrder()).forEach { Files.deleteIfExists(it) } + } + } } 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 a44a7b0d108..5718081914b 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 @@ -163,7 +163,9 @@ internal class KiloToolWindowSetupService( toolWindow.contentManager.setSelectedContent(chatContent) manager.newSession() - // Show a notification dot on the Agents tab whenever a worktree session needs attention. + // Notification dot on the Agents tab: up for as long as any worktree session is waiting + // on the user or has failed. Viewing the tab must not clear it — only resolving the + // attention does, so the dot stays a reliable "something still needs you" signal. val dot = cs.launch { project.service().activity.map(::sessionAttentionNeeded).collect { needed -> withContext(Dispatchers.Main) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentAttention.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentAttention.kt index b4506b00801..c5c13ede48e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentAttention.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentAttention.kt @@ -4,8 +4,12 @@ import ai.kilocode.rpc.dto.SessionActivityDto import ai.kilocode.rpc.dto.SessionActivityKindDto /** - * Whether any session in the activity snapshot is waiting on the user or has failed, - * i.e. the Agents tab should show a notification dot. + * Whether any session in the activity snapshot is waiting on the user or has failed, i.e. the Agents + * tab should show a notification dot. + * + * The dot mirrors that state for as long as it lasts, across every worktree and session. Viewing the + * tab does not clear it: only resolving the attention does, by answering the prompt or running the + * session again. */ internal fun sessionAttentionNeeded(activity: Map): Boolean = activity.values.any { 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 f5b424ae8f5..c5935761a46 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 @@ -39,6 +39,7 @@ import ai.kilocode.client.ui.list.ActiveListMetrics import ai.kilocode.client.ui.list.ActiveListReorder import ai.kilocode.client.ui.list.ActiveListSelection import ai.kilocode.client.ui.list.ActiveListSurface +import ai.kilocode.client.ui.list.ActiveListWeight import ai.kilocode.client.ui.list.activeListToolWindowBackground import ai.kilocode.client.vfs.KiloVfsManager import ai.kilocode.rpc.dto.RemoveWorktreeResultDto @@ -99,7 +100,11 @@ class AgentManagerPanel( private val group = ActionManager.getInstance().getAction("Kilo.Worktree.RowMenu") as? ActionGroup ?: DefaultActionGroup() private val list = ActiveList( KiloBundle.message("worktree.empty"), - cfg = ActiveListConfig(hoverActions = true), + cfg = ActiveListConfig( + hoverActions = true, + title = ActiveListWeight.PLAIN, + header = ActiveListWeight.PLAIN, + ), surface = ActiveListSurface.ToolWindow, showSearch = false, onCell = { _, _ -> }, @@ -513,6 +518,7 @@ class AgentManagerPanel( override val description: String get() = WorktreeTitle.fallback(dto.path) override val tooltip: String? get() = null override val icon = WorktreeIcons.forRow(progress != null, kind, dto.locked, current) + override val tinted: Boolean get() = WorktreeIcons.neutral(icon) 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) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeActivity.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeActivity.kt index 8422c556bf2..63321fc5bef 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeActivity.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeActivity.kt @@ -1,32 +1,29 @@ package ai.kilocode.client.agentManager.worktree import ai.kilocode.client.session.SessionActivityKind +import ai.kilocode.client.session.toKind import ai.kilocode.rpc.dto.SessionActivityDto -import ai.kilocode.rpc.dto.SessionActivityKindDto internal fun aggregateWorktreeActivity( activity: Map, ): Map = activity.values .groupBy { normalize(it.directory) } - .mapValues { (_, items) -> items.map { kind(it.kind) }.minBy(::rank) } + .mapValues { (_, items) -> items.map { it.kind.toKind() }.minBy(::rank) } internal fun normalizeWorktreePath(path: String): String = normalize(path) private fun normalize(path: String): String = path.trimEnd('/') -private fun kind(kind: SessionActivityKindDto): SessionActivityKind = when (kind) { - SessionActivityKindDto.RUNNING -> SessionActivityKind.RUNNING - SessionActivityKindDto.QUESTION -> SessionActivityKind.QUESTION - SessionActivityKindDto.PLAN -> SessionActivityKind.PLAN - SessionActivityKindDto.PERMISSION -> SessionActivityKind.PERMISSION - SessionActivityKindDto.ERROR -> SessionActivityKind.ERROR -} - +/** + * Precedence for a worktree holding several sessions: anything waiting on the user first, then live + * work, then a session left in an error. Running beats error so one stopped session cannot hide the + * spinner of a sibling that is still working. + */ private fun rank(kind: SessionActivityKind): Int = when (kind) { SessionActivityKind.PERMISSION -> 0 SessionActivityKind.QUESTION -> 1 SessionActivityKind.PLAN -> 2 - SessionActivityKind.ERROR -> 3 - SessionActivityKind.RUNNING -> 4 + SessionActivityKind.RUNNING -> 3 + SessionActivityKind.ERROR -> 4 SessionActivityKind.LOGIN_REQUIRED -> 5 } 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 9cd0f8939b6..8d9a6f84485 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 @@ -93,7 +93,7 @@ class WorktreeController( edt { val main = result.worktrees.firstOrNull { it.main } val extra = result.worktrees.filter { !it.main } - val rows = extra + pending.values + val rows = pending.values.toList().asReversed() + extra current = main model.replaceAll(rows) cache().putAll(rows) @@ -131,7 +131,7 @@ class WorktreeController( edt { pending[temp.id] = temp tasks[temp.id] = KiloBundle.message("worktree.progress.creating") - model.add(temp) + model.add(0, temp) onSelect?.invoke(temp.id) } cs.launch { @@ -146,7 +146,7 @@ class WorktreeController( edt { pending[temp.id] = temp tasks[temp.id] = KiloBundle.message("worktree.progress.creating") - model.add(temp) + model.add(0, temp) onSelect?.invoke(temp.id) } cs.launch { @@ -168,7 +168,7 @@ class WorktreeController( tasks.remove(temp.id) val idx = model.getElementIndex(temp) if (created != null) { - if (idx >= 0) model.setElementAt(created, idx) else model.add(created) + if (idx >= 0) model.setElementAt(created, idx) else model.add(0, created) cache().put(created) prompt?.let { service().put(created.path, it) } onSelect?.invoke(created.id) @@ -236,7 +236,7 @@ class WorktreeController( val temp = WorktreeDto("pending:$branch:${System.nanoTime()}", branch, branch, "pending:$branch") pending[temp.id] = temp tasks[temp.id] = label(MoveStage.CAPTURING) - model.add(temp) + model.add(0, temp) onSelect?.invoke(temp.id) cs.launch { var stage = MoveStage.CAPTURING @@ -254,7 +254,7 @@ class WorktreeController( 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) + if (idx >= 0) model.setElementAt(worktree, idx) else model.add(0, worktree) cache().put(worktree) // Queue the forked session for the editor the selection is about to // open; the tab's identity stays the worktree path alone. 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 5fe84fbd5bf..d34b9fa8dec 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 @@ -21,9 +21,9 @@ 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. 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. + * checkout, or a branch checkout — while a running, waiting or failed session takes the slot over + * so the list still surfaces activity at a glance. An operation on the row ([busy]) outranks all + * of it. */ fun forRow( busy: Boolean, @@ -37,12 +37,16 @@ internal object WorktreeIcons { SessionActivityKind.QUESTION, SessionActivityKind.PERMISSION, SessionActivityKind.PLAN, - SessionActivityKind.LOGIN_REQUIRED -> kind.icon() - SessionActivityKind.ERROR, null -> when { + SessionActivityKind.LOGIN_REQUIRED, + SessionActivityKind.ERROR -> kind.icon() + null -> when { current -> local locked -> this.locked else -> branch } } } + + /** The monochrome at-rest glyphs that follow the row text color; status icons are excluded. */ + fun neutral(icon: Icon?): Boolean = icon === local || icon === locked || icon === branch } 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 d87cb64c021..234f3ad5969 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 @@ -23,6 +23,7 @@ import ai.kilocode.client.ui.list.ActiveListMenu import ai.kilocode.client.ui.list.ActiveListRowHeight import ai.kilocode.client.ui.list.ActiveListSelection import ai.kilocode.client.ui.list.ActiveListSurface +import ai.kilocode.client.ui.list.ActiveListWeight import ai.kilocode.client.ui.list.activeListToolWindowBackground import ai.kilocode.client.ui.layout.Stack import ai.kilocode.client.vfs.KiloVfsManager @@ -95,6 +96,7 @@ class WorktreeSessionEditorPanel( description = false, selection = ListSelectionModel.MULTIPLE_INTERVAL_SELECTION, hoverActions = true, + title = ActiveListWeight.PLAIN, ), surface = ActiveListSurface.ToolWindow, showSearch = false, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatsView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatsView.kt index 6dfabc84cc4..68671254350 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatsView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatsView.kt @@ -22,16 +22,24 @@ import java.awt.BorderLayout import java.awt.Component import java.awt.Container import java.awt.Cursor -import java.awt.Dimension import java.awt.event.MouseAdapter import java.awt.event.MouseEvent import javax.swing.Icon import javax.swing.JPanel +/** + * The trailing ahead/behind/diff and PR badges of a worktree row. + * + * Uses a real layout manager on purpose: a `null` layout resolves min/preferred size through the + * peer, which reports the component's *current* size. Inside the list this view is a single render + * stamp reused for every row, and [Stack] and [ai.kilocode.client.ui.layout.Align] clamp a child's + * preferred width into its `[min, max]` range - so a peer-reported minimum would carry the previous + * row's width into the next row's layout and drift the badges off their hit regions. + */ internal class WorktreeStatsView( openDiff: (() -> Unit)? = null, fill: Boolean = true, -) : JPanel(null) { +) : JPanel(BorderLayout()) { companion object { private val UP: Icon = IconLoader.getIcon("/icons/arrow-up.svg", WorktreeStatsView::class.java) private val DOWN: Icon = IconLoader.getIcon("/icons/arrow-down-to-line.svg", WorktreeStatsView::class.java) @@ -51,11 +59,10 @@ internal class WorktreeStatsView( // it is always the rightmost element. private val row = Stack.horizontal(UiStyle.Gap.md()).next(changeHit).next(prHit) private var url: String? = null - private var stats: WorktreeStatsDto? = null - private var pull: WorktreePrDto? = null + private var state: State? = null init { - add(row) + add(row, BorderLayout.CENTER) changeHit.act = openDiff prHit.act = { url?.let(BrowserUtil::browse) } diff.toolTipText = KiloBundle.message("worktree.stats.tooltip", 0, 0, 0, 0) @@ -83,21 +90,29 @@ internal class WorktreeStatsView( } fun update(stats: WorktreeStatsDto?, pull: WorktreePrDto?) { - if (this.stats == stats && this.pull == pull) return - this.stats = stats - this.pull = pull - sync(stats, pull?.let { ActiveListBadge("#${it.number}", style(it.state)) }, pull?.url, pull?.let(::prTooltip)) + sync( + State( + stats, + pull?.let { ActiveListBadge("#${it.number}", style(it.state)) }, + pull?.url, + pull?.let(::prTooltip), + ), + ) } fun update(stats: WorktreeStatsDto?, badge: ActiveListBadge?, prTip: String? = badge?.text) { - if (this.stats == stats && pull == null && (pr.icon as? FilledBadgeIcon)?.text == badge?.text && prHit.tip == prTip) return - this.stats = stats - this.pull = null - sync(stats, badge, null, prTip) + sync(State(stats, badge, null, prTip)) } - private fun sync(stats: WorktreeStatsDto?, badge: ActiveListBadge?, link: String?, tip: String?) { - val s = stats ?: WorktreeStatsDto("") + /** + * Applies [next] unless it is already rendered. The memo key must cover everything this method + * writes: inside the list one instance renders every row, so a field left out of the key would + * carry another row's badge, tooltip, or visibility. + */ + private fun sync(next: State) { + if (state == next) return + state = next + val s = next.stats ?: WorktreeStatsDto("") behind.text = s.behind.toString() behind.toolTipText = KiloBundle.message("worktree.stats.behind.tooltip") behind.isVisible = s.behind > 0 @@ -112,12 +127,12 @@ internal class WorktreeStatsView( diff.toolTipText = changeTip changeHit.tip = changeTip changeHit.toolTipText = changeTip - url = link - pr.icon = badge?.let { FilledBadgeIcon(it.text, it.style) } - pr.toolTipText = tip - prHit.tip = tip - prHit.toolTipText = tip - pr.isVisible = badge != null + url = next.link + pr.icon = next.badge?.let { FilledBadgeIcon(it.text, it.style) } + pr.toolTipText = next.tip + prHit.tip = next.tip + prHit.toolTipText = next.tip + pr.isVisible = next.badge != null val changesVisible = behind.isVisible || ahead.isVisible || diff.isVisible changeHit.isVisible = changesVisible prHit.isVisible = pr.isVisible @@ -145,18 +160,6 @@ internal class WorktreeStatsView( if (comp is Container) comp.components.forEach { applyCursor(it, active) } } - override fun getPreferredSize(): Dimension { - val ins = insets - val size = row.preferredSize - return Dimension(size.width + ins.left + ins.right, size.height + ins.top + ins.bottom) - } - - override fun doLayout() { - val ins = insets - val size = row.preferredSize - row.setBounds(ins.left, ins.top, minOf(size.width, width - ins.left - ins.right), minOf(size.height, height - ins.top - ins.bottom)) - } - private fun count(icon: Icon) = JBLabel().apply { this.icon = icon iconTextGap = UiStyle.Gap.xs() @@ -165,6 +168,14 @@ internal class WorktreeStatsView( border = JBUI.Borders.empty() } + /** Everything [sync] renders, so a repeated row can be skipped without leaking stale state. */ + private data class State( + val stats: WorktreeStatsDto?, + val badge: ActiveListBadge?, + val link: String?, + val tip: String?, + ) + /** A badge wrapper the ActiveList hit-tests for clicks, cursor, and tooltip. */ private class HitRegion(override val cellId: String) : JPanel(BorderLayout()), ActiveListHitCell { var act: (() -> Unit)? = null diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt index 0d1615644f2..422859cd886 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt @@ -5,6 +5,7 @@ package ai.kilocode.client.app import ai.kilocode.log.ChatLogSummary import ai.kilocode.rpc.KiloSessionRpcApi import ai.kilocode.client.session.SessionActivityKind +import ai.kilocode.client.session.toKind import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.CloudSessionListDto import ai.kilocode.rpc.dto.ConfigUpdateDto @@ -34,10 +35,12 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch /** @@ -68,13 +71,21 @@ class KiloSessionService internal constructor( private val _sessions = MutableStateFlow>(emptyList()) val sessions: StateFlow> = _sessions.asStateFlow() - /** Live session status map from SSE events. */ - val statuses: StateFlow> = - stream { statuses() }.stateIn(cs, SharingStarted.Eagerly, emptyMap()) + // Sessions deleted this run. The backend does not always emit a status/activity clear for a + // session left in a waiting or failed state, so a deleted question/error entry would otherwise + // linger and keep its badge on the session list, worktree list, and tab attention dot. Pruning + // it locally forces every derived status to re-evaluate the moment the delete resolves. + private val removed = MutableStateFlow>(emptySet()) - /** Live session activity map from backend global events. */ + /** Live session status map from SSE events, minus sessions deleted this run. */ + val statuses: StateFlow> = + combine(stream { statuses() }, removed) { map, gone -> map - gone } + .stateIn(cs, SharingStarted.Eagerly, emptyMap()) + + /** Live session activity map from backend global events, minus sessions deleted this run. */ val activity: StateFlow> = - stream { activity() }.stateIn(cs, SharingStarted.Eagerly, emptyMap()) + combine(stream { activity() }, removed) { map, gone -> map - gone } + .stateIn(cs, SharingStarted.Eagerly, emptyMap()) /** * Session create/update/delete across every directory the CLI serves, including sessions @@ -109,10 +120,15 @@ class KiloSessionService internal constructor( } } - internal fun activitySnapshot(): Map = - statuses.value - .filterValues { it.type == "busy" } - .mapValues { SessionActivityKind.RUNNING } + /** + * Per-session activity for history and session lists. [activity] is the richer source — it also + * carries waiting and failed sessions, and it covers sessions that are not open — but it drops + * sessions whose directory the backend cannot resolve, so the busy statuses stay as a fallback. + */ + internal fun activitySnapshot(): Map { + val busy = statuses.value.filterValues { it.type == "busy" }.mapValues { SessionActivityKind.RUNNING } + return busy + activity.value.mapValues { it.value.kind.toKind() } + } suspend fun list(dir: String): SessionListDto { val result = call { list(dir) } @@ -159,6 +175,7 @@ class KiloSessionService internal constructor( log.info("${ChatLogSummary.sid(id)} kind=session delete=true dir=${ChatLogSummary.dir(dir)}") call { delete(id, dir) } log.info("${ChatLogSummary.sid(id)} kind=session delete=true ok=true dir=${ChatLogSummary.dir(dir)}") + removed.update { it + id } list(dir) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionActivityKind.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionActivityKind.kt index d82dae0e3bd..f7403c3f200 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionActivityKind.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionActivityKind.kt @@ -2,6 +2,7 @@ package ai.kilocode.client.session import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.ui.UiStyle +import ai.kilocode.rpc.dto.SessionActivityKindDto import javax.swing.Icon enum class SessionActivityKind { @@ -30,3 +31,15 @@ enum class SessionActivityKind { fun icon(): Icon = ActivityIcon.of(this) } + +/** + * The backend reports activity for every session it knows, open or not. LOGIN_REQUIRED has no DTO + * counterpart: it comes from live session UI state instead. + */ +internal fun SessionActivityKindDto.toKind(): SessionActivityKind = when (this) { + SessionActivityKindDto.RUNNING -> SessionActivityKind.RUNNING + SessionActivityKindDto.QUESTION -> SessionActivityKind.QUESTION + SessionActivityKindDto.PLAN -> SessionActivityKind.PLAN + SessionActivityKindDto.PERMISSION -> SessionActivityKind.PERMISSION + SessionActivityKindDto.ERROR -> SessionActivityKind.ERROR +} 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 83789ad5a65..8d9a07ff8e3 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 @@ -461,7 +461,9 @@ class SessionUi( hostedInEditorTab = manager?.hostedInEditorTab == true, ) connection = ConnectionPanel(this, controller) - root.addOverlay(connection) { pane, child -> + // The banner reports a broken session, so it owns the pointer where it sits: the transcript + // under it must not stay hovered and keep a popup open behind it. + root.addOverlay(connection, blocks = true) { pane, child -> val size = child.preferredSize if (readonly) { val gap = SessionUiStyle.View.contentGap() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt index d64c4c77673..7d42ce94551 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt @@ -2308,6 +2308,9 @@ class SessionController( private fun setControllerViewState(event: SessionControllerEvent.ViewChanged) { assertEdt() if (disposed) return + // A late empty history load must not re-show the empty screen after a prompt opened the + // transcript. + if (event is SessionControllerEvent.ViewChanged.ShowEmpty && model.showSession) return if (event is SessionControllerEvent.ViewChanged.ShowSession) openLocal() if (viewState == event) return fire(event) { @@ -2411,6 +2414,14 @@ class SessionController( ) } + if (workspace.status == KiloWorkspaceStatusDto.MISSING) { + return SessionControllerEvent.ConnectionChanged.ShowError( + KiloBundle.message("session.connection.missing"), + KiloBundle.message("session.connection.missing.detail", workspace.error ?: directory), + "workspace", + ) + } + if (app.status == KiloAppStatusDto.READY && workspace.status == KiloWorkspaceStatusDto.READY && app.warnings.isNotEmpty()) { return SessionControllerEvent.ConnectionChanged.ShowWarning( summary(app.warnings.size), diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupController.kt index 7995bb51295..38d9892c34f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupController.kt @@ -120,7 +120,7 @@ class HeaderPopupController(timers: UiTimerSource = UiTimers) : Disposable { if (!onHeader && !onPopup) return hideAll() val req = view.headerPopup() ?: return hideAll() val built = req.build() - place(req.anchor, built)?.let { open(req, built, it) } ?: hideAll() + place(view, req.anchor, built)?.let { open(req, built, it) } ?: hideAll() } @RequiresEdt @@ -131,6 +131,7 @@ class HeaderPopupController(timers: UiTimerSource = UiTimers) : Disposable { .setBorderColor(UiStyle.Balloon.border()) .setBorderInsets(UiStyle.Balloon.insets()) .setPointerSize(UiStyle.Balloon.pointer()) + .setCornerToPointerDistance(spot.distance) .setCornerRadius(UiStyle.Balloon.arc()) .setHideOnClickOutside(true) .setHideOnKeyOutside(true) @@ -160,14 +161,16 @@ class HeaderPopupController(timers: UiTimerSource = UiTimers) : Disposable { } /** - * Resolves the pointer target beside the session chat, sizing the body to the space available on - * the chosen side. Anchoring on the chat rather than the hovered row is what keeps the popup off - * the transcript instead of covering the row the user is reading. + * Resolves the pointer target beside [card], the collapsible view the popup belongs to, sizing the + * body to the space available on the chosen side and to the visible height of the chat. Pointing at + * the card rather than the hovered row keeps the popup off the transcript instead of covering the + * row the user is reading, and pointing at the card rather than the session edge keeps the balloon + * attached to the thing it describes. * * Returns null when the chat is not on screen yet, in which case there is nothing to sit beside. */ @RequiresEdt - private fun place(anchor: JComponent, built: HeaderPopupBody): Spot? { + private fun place(card: JComponent, anchor: JComponent, built: HeaderPopupBody): Spot? { val pane = SwingUtilities.getRootPane(anchor)?.layeredPane val chat = ComponentUtil.getParentOfType(SessionRootPanel::class.java, anchor) // A showing anchor implies every ancestor, including the chat, is showing and laid out. @@ -175,14 +178,19 @@ class HeaderPopupController(timers: UiTimerSource = UiTimers) : Disposable { val gap = UiStyle.Gap.pad() val insets = UiStyle.Balloon.insets() // The shadow is reserved on every side, so it counts twice on each axis. - val shadow = UiStyle.Balloon.shadow() * 2 - val chromeHeight = insets.top + insets.bottom + shadow - val bounds = Rectangle(pane.size) + val shadow = UiStyle.Balloon.shadow() + val chromeHeight = insets.top + insets.bottom + shadow * 2 + // The visible chat rect, not the whole panel: a session clipped by a short tool window or a + // scrolled editor tab must keep its popups inside the part the user can actually see. + val area = SwingUtilities.convertRectangle(chat, chat.visibleRect, pane) + if (area.isEmpty) return null + val rect = SwingUtilities.convertRectangle(card.parent, card.bounds, pane) val spot = HeaderPopupGeometry.beside( - pane = bounds, - chat = SwingUtilities.convertRectangle(chat.parent, chat.bounds, pane), + pane = Rectangle(pane.size), + card = rect, + view = area, fit = HeaderPopupFit( - chromeWidth = insets.left + insets.right + UiStyle.Balloon.pointer().height + shadow, + chromeWidth = insets.left + insets.right + UiStyle.Balloon.pointer().height + shadow * 2, chromeHeight = chromeHeight, gap = gap, maxWidth = JBUI.scale(SessionUiStyle.View.Popup.WIDE_MAX_WIDTH), @@ -191,11 +199,20 @@ class HeaderPopupController(timers: UiTimerSource = UiTimers) : Disposable { ) built.fitWithin(spot.maxWidth, spot.maxHeight) val row = SwingUtilities.convertPoint(anchor, Point(0, anchor.height / 2), pane) - val height = built.component.preferredSize.height + chromeHeight - return Spot(pane, Point(spot.x, HeaderPopupGeometry.centerY(bounds, row.y, height, gap)), spot.position) + val view = Rectangle(area.x, area.y + shadow, area.width, (area.height - shadow * 2).coerceAtLeast(0)) + val height = built.component.preferredSize.height + insets.top + insets.bottom + val aim = HeaderPopupGeometry.aim( + view = view, + card = rect, + y = row.y, + height = height, + gap = gap, + indent = UiStyle.Balloon.arc() + UiStyle.Balloon.pointer().width / 2, + ) + return Spot(pane, Point(spot.x, aim.y), spot.position, aim.distance) } - private class Spot(val pane: JComponent, val point: Point, val position: Balloon.Position) + private class Spot(val pane: JComponent, val point: Point, val position: Balloon.Position, val distance: Int) private companion object { const val SHOW_MS = 500 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupGeometry.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupGeometry.kt index 6a790e9b3d6..20b3ce80307 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupGeometry.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupGeometry.kt @@ -4,7 +4,7 @@ import com.intellij.openapi.ui.popup.Balloon import java.awt.Rectangle /** - * Where a header popup should sit relative to the session chat, and how large its body may be. + * Where a header popup should sit relative to its card, and how large its body may be. * * [x] is the pointer target in the same coordinate space the placement was computed in. */ @@ -30,41 +30,82 @@ internal data class HeaderPopupFit( val maxHeight: Int, ) +/** + * Vertical pointer target and the distance from the balloon top to that target. + */ +internal data class HeaderPopupAim(val y: Int, val distance: Int) + /** * Geometry for header popups. Pure functions so the side and fit rules are testable without a frame. * - * Header popups only ever sit beside the chat, never over it and never above or below it. The fit part + * Header popups only ever sit beside their card, never over it and never above or below it. The fit part * is not cosmetic: `BalloonImpl.show` silently re-points a balloon to `BELOW`/`ABOVE` when the * requested rectangle does not fit inside the layered pane, so a body that overflows its side would * land in exactly the placement we are avoiding. Capping the body keeps the requested position. */ internal object HeaderPopupGeometry { - /** Picks the side of [chat] with more room inside [pane] and the body box that fits there. */ - fun beside(pane: Rectangle, chat: Rectangle, fit: HeaderPopupFit): HeaderPopupPlacement { - val left = (chat.x - pane.x).coerceAtLeast(0) - val right = (pane.x + pane.width - (chat.x + chat.width)).coerceAtLeast(0) + /** + * Picks the side of [card] with more room inside [pane] and the body box that fits there. + * + * The pointer lands on the edge of [card], the collapsible view the popup belongs to, so the + * balloon reads as attached to that card instead of docked to the far edge of the session. Room + * is still measured against [pane]: a card is narrower than the session, and cards near the + * middle of a split editor have almost no room beside them inside the session itself. + * + * [view] is the visible session and only budgets height. Using [card] there would collapse the + * body, since a collapsed card header is a couple of rows tall. + */ + fun beside(pane: Rectangle, card: Rectangle, view: Rectangle, fit: HeaderPopupFit): HeaderPopupPlacement { + val left = (card.x - pane.x).coerceAtLeast(0) + val right = (pane.x + pane.width - (card.x + card.width)).coerceAtLeast(0) // Ties go right: it matches reading direction and the common tool-window-on-the-left setup. val useRight = right >= left val room = (if (useRight) right else left) - fit.chromeWidth - fit.gap return HeaderPopupPlacement( position = if (useRight) Balloon.Position.atRight else Balloon.Position.atLeft, - x = if (useRight) chat.x + chat.width else chat.x, + x = if (useRight) card.x + card.width else card.x, maxWidth = room.coerceIn(0, fit.maxWidth), - maxHeight = (pane.height - fit.gap * 2 - fit.chromeHeight).coerceIn(0, fit.maxHeight), + // Height is budgeted against the session, not the pane: the popup belongs to the session + // view, so it must not run past it into editor tabs or neighbouring tool windows. + maxHeight = (view.height - fit.gap * 2 - fit.chromeHeight).coerceIn(0, fit.maxHeight), ) } /** - * Vertical pointer target for a body of [height], preferring [y] but keeping the balloon inside - * [pane]. The balloon centres its body on the target, so an unclamped target near an edge would - * overflow and trigger the same re-pointing that [beside] avoids horizontally. + * Keeps the pointer on [card] while moving the balloon body into [view]. The returned [distance] + * is the value the platform uses as `cornerToPointerDistance`, which makes the body slide without + * moving the pointer target off the element it describes. */ - fun centerY(pane: Rectangle, y: Int, height: Int, gap: Int): Int { - val half = height / 2 - val top = pane.y + gap + half - val bottom = pane.y + pane.height - gap - half - if (bottom < top) return pane.y + pane.height / 2 - return y.coerceIn(top, bottom) + fun aim(view: Rectangle, card: Rectangle, y: Int, height: Int, gap: Int, indent: Int): HeaderPopupAim { + val hit = card.intersection(view) + if (hit.isEmpty) return fallback(view, height, gap, indent) + val pointer = clamp(y, hit.y + indent, hit.y + hit.height - indent) + val top = top(view, pointer, height, gap) + return HeaderPopupAim(y = pointer, distance = legal(pointer - top, height, indent)) + } + + private fun fallback(view: Rectangle, height: Int, gap: Int, indent: Int): HeaderPopupAim { + val y = view.y + view.height / 2 + val top = top(view, y, height, gap) + return HeaderPopupAim(y = y, distance = legal(y - top, height, indent)) + } + + private fun top(view: Rectangle, y: Int, height: Int, gap: Int): Int { + val min = view.y + gap + val max = view.y + view.height - gap - height + if (max < min) return view.y + (view.height - height) / 2 + return (y - height / 2).coerceIn(min, max) + } + + private fun legal(distance: Int, height: Int, indent: Int): Int { + val max = height - indent + if (max < indent) return height / 2 + return distance.coerceIn(indent, max) + } + + private fun clamp(value: Int, min: Int, max: Int): Int { + if (max < min) return min + (max - min) / 2 + return value.coerceIn(min, max) } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt index 20453d2f366..551f6db45a4 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt @@ -301,9 +301,21 @@ abstract class AbstractSessionPartView( } } + /** + * Whether the pointer is still on the row. Bounds alone are not enough: an overlay painted above + * the transcript (the connection banner, the modal blocker) owns the pointer while sitting inside + * the row's rectangle, and Swing stops delivering to the row without ever leaving it + * geometrically. Asking which component is topmost at that point treats a covered row as left, so + * the exit clears the hover instead of keeping the row lit — and its popup alive — under the + * overlay. + */ private fun inside(e: MouseEvent): Boolean { val point = SwingUtilities.convertPoint(e.component, e.point, row) - return row.contains(point) + if (!row.contains(point)) return false + val pane = SwingUtilities.getRootPane(row)?.layeredPane ?: return true + val spot = SwingUtilities.convertPoint(e.component, e.point, pane) + val top = SwingUtilities.getDeepestComponentAt(pane, spot.x, spot.y) ?: return true + return SwingUtilities.isDescendingFrom(top, row) } /** diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/LayeredOverlayPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/LayeredOverlayPanel.kt index a9aeedcf792..42b4a4b63fc 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/LayeredOverlayPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/LayeredOverlayPanel.kt @@ -7,12 +7,20 @@ import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.JBUI import com.intellij.util.ui.components.BorderLayoutPanel import java.awt.BorderLayout +import java.awt.Component import java.awt.Container import java.awt.Dimension +import java.awt.GraphicsEnvironment +import java.awt.MouseInfo +import java.awt.Point import java.awt.Rectangle +import java.awt.event.ComponentAdapter +import java.awt.event.ComponentEvent +import java.awt.event.MouseEvent import javax.swing.JComponent import javax.swing.JLayeredPane import javax.swing.JPanel +import javax.swing.SwingUtilities open class LayeredOverlayPanel( content: JPanel = BorderLayoutPanel(), @@ -32,6 +40,17 @@ open class LayeredOverlayPanel( open val blocker: Blocker get() = baseBlocker + // An overlay that starts covering the pointer takes the hover over from the content below it. + // Swing already stops delivering mouse events to a covered component, but it sends no exit when + // the cover appears or moves without the pointer moving, so the content would keep its hover — + // and any hover-driven popup — alive behind the overlay. + private val cover = object : ComponentAdapter() { + override fun componentShown(e: ComponentEvent) = takeOverHover() + override fun componentHidden(e: ComponentEvent) = takeOverHover() + override fun componentMoved(e: ComponentEvent) = takeOverHover() + override fun componentResized(e: ComponentEvent) = takeOverHover() + } + init { layout = null add(baseContent) @@ -41,10 +60,17 @@ open class LayeredOverlayPanel( add(baseBlocker) setLayer(baseBlocker, MODAL_LAYER) baseBlocker.isVisible = false + baseOverlay.cover = cover + baseBlocker.addComponentListener(cover) } - fun addOverlay(child: JComponent, bounds: (JPanel, JComponent) -> Rectangle) { - overlay.addOverlay(child, bounds) + /** + * Adds a floating child above the content. A child that [blocks] owns the pointer where it sits: + * it takes the hover over from the content beneath it, which a decoration painted for the content + * below (a hover affordance of the very row it sits on) must not do. + */ + fun addOverlay(child: JComponent, blocks: Boolean = false, bounds: (JPanel, JComponent) -> Rectangle) { + overlay.addOverlay(child, blocks, bounds) } @RequiresEdt @@ -89,6 +115,41 @@ open class LayeredOverlayPanel( } } + /** + * Hands the hover of the content under the pointer over to the overlay that now covers it. + * Deferred because the trigger can arrive mid-layout, while a hover handler is free to close a + * popup or re-lay out the card it belongs to. + */ + private fun takeOverHover() = SwingUtilities.invokeLater(::releaseHover) + + @RequiresEdt + private fun releaseHover() { + if (GraphicsEnvironment.isHeadless() || !isShowing) return + val point = MouseInfo.getPointerInfo()?.location ?: return + SwingUtilities.convertPointFromScreen(point, this) + releaseHover(point) + } + + /** Releases the hover of the content at [point], in this panel's coordinates, when covered. */ + @RequiresEdt + internal fun releaseHover(point: Point) { + if (!covered(point)) return + val local = SwingUtilities.convertPoint(this, point, content) + val below = SwingUtilities.getDeepestComponentAt(content, local.x, local.y) ?: return + val spot = SwingUtilities.convertPoint(this, point, below) + below.dispatchEvent( + MouseEvent(below, MouseEvent.MOUSE_EXITED, System.currentTimeMillis(), 0, spot.x, spot.y, 0, false), + ) + } + + /** Whether the blocker or a blocking overlay child sits above the content at [point]. */ + private fun covered(point: Point): Boolean { + if (!Rectangle(size).contains(point)) return false + if (blocker.isVisible) return true + val local = SwingUtilities.convertPoint(this, point, overlay) + return overlay.blocks(local.x, local.y) + } + override fun getPreferredSize(): Dimension { val w = listOf(content, overlay).maxOfOrNull { it.preferredSize.width } ?: 0 val h = listOf(content, overlay).maxOfOrNull { it.preferredSize.height } ?: 0 @@ -99,22 +160,32 @@ open class LayeredOverlayPanel( private val items = linkedMapOf Rectangle>() + private val blocking = linkedSetOf() + + /** Notified when a blocking child is shown, hidden, moved, or resized. */ + internal var cover: ComponentAdapter? = null + init { layout = null isOpaque = false } - fun addOverlay(child: JComponent, bounds: (JPanel, JComponent) -> Rectangle) { + fun addOverlay(child: JComponent, blocks: Boolean = false, bounds: (JPanel, JComponent) -> Rectangle) { items[child] = bounds + if (blocks) { + blocking.add(child) + cover?.let(child::addComponentListener) + } add(child) } - override fun contains(x: Int, y: Int): Boolean { - for (child in components) { - if (child.isVisible && child.bounds.contains(x, y) && child.contains(x - child.x, y - child.y)) return true - } - return false - } + override fun contains(x: Int, y: Int): Boolean = components.any { hits(it, x, y) } + + /** Whether a child that blocks the content beneath it covers ([x], [y]). */ + internal fun blocks(x: Int, y: Int): Boolean = blocking.any { hits(it, x, y) } + + private fun hits(child: Component, x: Int, y: Int): Boolean = + child.isVisible && child.bounds.contains(x, y) && child.contains(x - child.x, y - child.y) override fun doLayout() { items.forEach { (child, bounds) -> 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 bce69427479..9698bf5e087 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 @@ -32,6 +32,8 @@ internal data class ActiveListMetrics( internal enum class ActiveListRowHeight { EQUAL, PREFERRED } +internal enum class ActiveListWeight { PLAIN, BOLD } + internal data class ActiveListConfig( val height: ActiveListRowHeight = ActiveListRowHeight.EQUAL, val description: Boolean = true, @@ -39,6 +41,12 @@ internal data class ActiveListConfig( val tooltip: Boolean = true, val selection: Int = ListSelectionModel.SINGLE_SELECTION, val hoverActions: Boolean = false, + /** Weight used for the primary row title. */ + val title: ActiveListWeight = ActiveListWeight.BOLD, + /** Weight used for section headers. */ + val header: ActiveListWeight = ActiveListWeight.BOLD, + /** Show a separator line above section headers, except above the first row. */ + val divider: Boolean = true, ) { companion object { val Equal = ActiveListConfig(ActiveListRowHeight.EQUAL) @@ -83,10 +91,10 @@ internal interface ActiveListHitCell { /** * A row in an [ActiveList]. Carries the display contract shared by settings pages, the worktree - * list, and the session history stack: a leading icon, a bold title with an inline [note], a - * secondary [description] line, inline [badges], optional right-aligned [trailing] text, and - * action [cells]. Action cells are shown only for the active focused selection unless - * [ActiveListCell.alwaysVisible] is true. + * list, and the session history stack: a leading icon, a title whose weight follows + * [ActiveListConfig.title] with an inline [note], a secondary [description] line, inline [badges], + * optional right-aligned [trailing] text, and action [cells]. Action cells are shown only for the + * active focused selection unless [ActiveListCell.alwaysVisible] is true. */ internal interface ActiveListItem { val key: String @@ -102,6 +110,12 @@ internal interface ActiveListItem { val tooltip: String? get() = description val doubleClick: String? get() = null val icon: Icon? get() = null + /** + * Recolor [icon] to the row foreground when the row is the focused selection. Enable it only for + * monochrome glyphs that should read as part of the highlighted text; leave it off for colored + * status icons (running, question, error) so they keep their own hue. + */ + val tinted: Boolean get() = false val section: String? get() = null val badges: List get() = emptyList() /** Right-aligned secondary text, such as a relative timestamp. */ @@ -236,6 +250,21 @@ internal fun activeListLayout(component: Component) { for (child in component.components) activeListLayout(child) } +/** + * Marks a rendered row and everything under it invalid. + * + * A list renderer is one component reused for every row, and it changes content without changing + * size. Swing caches each container's preferred/minimum size and - through + * [java.awt.Container.validate], the layout pass painting uses - skips subtrees that are still + * valid, so a row would otherwise be laid out with sizes measured for whichever row the renderer + * rendered before it. Invalidating the whole stamp keeps painting and the [activeListLayout] pass + * behind [activeListHits] on the same geometry. + */ +internal fun activeListInvalidate(component: Component) { + component.invalidate() + if (component is Container) for (child in component.components) activeListInvalidate(child) +} + private fun forEachHitCell(component: Component, action: (ActiveListHitCell) -> Unit) { fun visit(c: Component) { // Skip hidden subtrees so a badge left visible inside a hidden trailing panel is not 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 9c603785a2b..735f46a9399 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 @@ -12,9 +12,11 @@ import ai.kilocode.client.ui.layout.align import com.intellij.icons.AllIcons import com.intellij.ui.CollectionListModel import com.intellij.ui.GroupHeaderSeparator +import com.intellij.ui.RelativeFont import com.intellij.ui.SimpleColoredComponent import com.intellij.ui.SimpleTextAttributes import com.intellij.ui.components.JBLabel +import com.intellij.util.IconUtil import com.intellij.util.ui.EmptyIcon import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil @@ -197,14 +199,7 @@ internal class ActiveListRenderer( background = list.background top.background = list.background wrap.update(list, selected, active) - sep.caption = section - sep.setHideLine(index == 0) - top.isVisible = section != null - top.setPreferredSize(section?.let { - val height = sep.preferredSize.height - .coerceAtLeast(sep.getFontMetrics(sep.font).height + insets.top + insets.bottom) - Dimension(0, height + JBUI.scale(2)) - }) + syncHeader(section, index) if (value is ActiveListGap) { gap = true @@ -213,21 +208,25 @@ internal class ActiveListRenderer( glyph.isVisible = false wrap.update(list, false, false) wrap.setPreferredSize(Dimension(0, bodyHeight ?: value.height)) - top.invalidate() + activeListInvalidate(this) return this } gap = false layers.isVisible = true title.clear() - // Bold carries the row: the description under it and the icon beside it both render in the - // muted secondary color, so weight is what separates the two lines rather than color alone. - title.append(value.title, SimpleTextAttributes(SimpleTextAttributes.STYLE_BOLD, titleFg)) + // Bold carries most rows by default: the description under it and the icon beside it both + // render in the muted secondary color, so cfg.title separates the two lines when enabled. + val style = if (cfg.title == ActiveListWeight.BOLD) SimpleTextAttributes.STYLE_BOLD else SimpleTextAttributes.STYLE_PLAIN + title.append(value.title, SimpleTextAttributes(style, titleFg)) value.note?.takeIf { it.isNotBlank() }?.let { title.append(" $it", SimpleTextAttributes.GRAYED_ATTRIBUTES) } syncBadges(value) - icon.icon = value.icon + // A selected row paints its title in the selection foreground; recolor a tinted glyph to + // match so it reads as part of the highlighted text. Colored status icons opt out and keep + // their own hue. + icon.icon = value.icon?.let { if (active && value.tinted) IconUtil.colorize(it, fg, keepBrightness = false) else it } mark.isVisible = value.icon != null val note = if (cfg.description) value.description.orEmpty() else "" desc.text = note @@ -263,10 +262,30 @@ internal class ActiveListRenderer( pill.background = if (selected && list.isEnabled) UIUtil.getListBackground(true, active) else list.background val height = bodyHeight wrap.setPreferredSize(height?.let { Dimension(0, it) }) - top.invalidate() + // Neither the content mutations above nor setPreferredSize invalidate reliably: a same-size + // icon swap, an equal label text, or an explicit preferred size leave the tree valid, and a + // valid subtree keeps the sizes it was measured with for another row. + activeListInvalidate(this) return this } + private fun syncHeader(section: String?, index: Int) { + sep.caption = section + sep.setHideLine(!cfg.divider || index == 0) + val font = if (cfg.header == ActiveListWeight.BOLD) { + RelativeFont.BOLD.derive(sep.font) + } else { + RelativeFont.PLAIN.derive(sep.font) + } + if (sep.font != font) sep.font = font + top.isVisible = section != null + top.setPreferredSize(section?.let { + val height = sep.preferredSize.height + .coerceAtLeast(sep.getFontMetrics(sep.font).height + insets.top + insets.bottom) + Dimension(0, height + JBUI.scale(2)) + }) + } + override fun paintChildren(g: Graphics) { super.paintChildren(g) if (!gap) return diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktree-local.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktree-local.svg index c5481bc9297..d1ce9fbc689 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktree-local.svg +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktree-local.svg @@ -1,4 +1,4 @@ - - + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktree-local_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktree-local_dark.svg index d40fa8f60f4..8766d725434 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktree-local_dark.svg +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktree-local_dark.svg @@ -1,4 +1,4 @@ - - + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktreeBranch.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktreeBranch.svg index 58184b7a91b..453719a6b27 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktreeBranch.svg +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktreeBranch.svg @@ -1 +1 @@ - + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktreeBranch_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktreeBranch_dark.svg index 00357612820..0f09a7c6881 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktreeBranch_dark.svg +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktreeBranch_dark.svg @@ -1 +1 @@ - + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktreeLock.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktreeLock.svg index bf088c0803d..75e8439d11b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktreeLock.svg +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktreeLock.svg @@ -1 +1 @@ - + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktreeLock_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktreeLock_dark.svg index d679769eb60..9037685be8c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktreeLock_dark.svg +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/worktreeLock_dark.svg @@ -1 +1 @@ - + 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 765851c3d4f..0207e13b1b7 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -13,6 +13,8 @@ session.connection.downloading.version=Downloading Kilo Core v{0} ({1})… {2}% session.connection.error.app=Connection failed session.connection.error.workspace=Workspace loading failed session.connection.error.unknown=Unknown error +session.connection.missing=Workspace folder missing +session.connection.missing.detail=Kilo can''t load this session because the workspace folder no longer exists: {0} session.connection.retry=Try again session.connection.unsupported=Workspace not supported session.connection.unsupported.devcontainer=Kilo runs on your host machine, so it can't reach the files inside this Dev Container. diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentAttentionTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentAttentionTest.kt index e105475abc1..14946e648ba 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentAttentionTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentAttentionTest.kt @@ -15,13 +15,29 @@ class AgentAttentionTest { SessionActivityKindDto.PERMISSION, SessionActivityKindDto.ERROR, )) { - assertTrue(sessionAttentionNeeded(mapOf("ses" to SessionActivityDto("/repo/wt", kind))), kind.name) + assertTrue(sessionAttentionNeeded(activity(kind)), kind.name) } } @Test fun `running and empty do not light up the dot`() { assertFalse(sessionAttentionNeeded(emptyMap())) - assertFalse(sessionAttentionNeeded(mapOf("ses" to SessionActivityDto("/repo/wt", SessionActivityKindDto.RUNNING)))) + assertFalse(sessionAttentionNeeded(activity(SessionActivityKindDto.RUNNING))) } + + @Test + fun `one session needing attention lights the dot for the whole snapshot`() { + val mixed = mapOf( + "ses_running" to SessionActivityDto("/repo/a", SessionActivityKindDto.RUNNING), + "ses_failed" to SessionActivityDto("/repo/b", SessionActivityKindDto.ERROR), + ) + + assertTrue(sessionAttentionNeeded(mixed)) + // Only resolving it clears the dot, however often the state is re-evaluated. + assertTrue(sessionAttentionNeeded(mixed)) + assertFalse(sessionAttentionNeeded(mixed - "ses_failed")) + } + + private fun activity(kind: SessionActivityKindDto) = + mapOf("ses_1" to SessionActivityDto("/repo/wt", kind)) } 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 035a6f33438..abdfd6f9100 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 @@ -24,6 +24,7 @@ import ai.kilocode.client.testing.TestCoroutines import ai.kilocode.client.testing.pumpEdt import ai.kilocode.client.testing.TestUiTimers import ai.kilocode.client.testing.fire +import ai.kilocode.client.testing.installBrowser import ai.kilocode.client.ui.list.ActiveListBadge import ai.kilocode.client.ui.list.ActiveListItem import ai.kilocode.client.ui.list.ActiveListMetrics @@ -51,9 +52,13 @@ import com.intellij.openapi.vfs.VirtualFile import com.intellij.ui.SearchTextField import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.testFramework.replaceService +import com.intellij.ui.SimpleColoredComponent +import com.intellij.ui.SimpleTextAttributes import com.intellij.ui.components.JBList import com.intellij.ui.components.JBScrollPane import com.intellij.util.ui.UIUtil +import java.awt.Component +import java.awt.Container import java.awt.event.MouseEvent import java.awt.Point import javax.swing.JComponent @@ -69,6 +74,7 @@ class AgentManagerPanelTest : BasePlatformTestCase() { override fun setUp() { super.setUp() + installBrowser() coroutines = TestCoroutines() rpc = FakeWorktreeRpcApi() service = KiloWorktreeService(coroutines.scope, rpc) @@ -98,13 +104,13 @@ class AgentManagerPanelTest : BasePlatformTestCase() { edt { controller.create("feature/y", null) } val list = edt { UIUtil.findComponentOfType(panel, JBList::class.java)!! } - val pendingId = edt { controller.model.getElementAt(controller.model.size - 1).id } + val pendingId = edt { controller.model.getElementAt(0).id } assertEquals(pendingId, edt { (list.selectedValue as ActiveListItem).key }) gate.complete(Unit) flush() - val created = edt { controller.model.getElementAt(controller.model.size - 1) } + val created = edt { controller.model.getElementAt(0) } assertEquals("feature/y", created.branch) assertEquals(created.id, edt { (list.selectedValue as ActiveListItem).key }) } @@ -212,6 +218,26 @@ class AgentManagerPanelTest : BasePlatformTestCase() { assertEquals(0, edt { scroll.viewportBorder.getBorderInsets(scroll).top }) } + fun `test worktree list renders row titles in plain weight`() { + rpc.listed += worktree("aardvark") + val controller = WorktreeController(service, project.basePath!!, coroutines.scope) + val panel = edt { AgentManagerPanel(testRootDisposable, controller, project) } + edt { controller.reload() } + flush() + + @Suppress("UNCHECKED_CAST") + val list = edt { UIUtil.findComponentOfType(panel, JBList::class.java)!! as JBList } + val title = edt { + val row = list.model.getElementAt(0) + val comp = list.cellRenderer.getListCellRendererComponent(list, row, 0, false, false) + components(comp).filterIsInstance().single() + } + val iter = title.iterator() + iter.next() + + assertEquals(SimpleTextAttributes.STYLE_PLAIN, iter.textAttributes.style) + } + fun `test clicking a worktree opens the worktree session editor`() { val item = WorktreeDto("/repo/.kilo/worktrees/feature-x", "feature-x", "feature/x", "${project.basePath!!}/.kilo/worktrees/feature-x") rpc.listed += item @@ -561,7 +587,7 @@ class AgentManagerPanelTest : BasePlatformTestCase() { assertEquals(emptyList(), row.badges) } - fun `test worktree row uses the branch icon for error activity`() { + fun `test worktree row uses the error icon for error activity`() { val item = WorktreeDto("/repo/.kilo/worktrees/feature-x", "feature-x", "feature/x", "/repo/.kilo/worktrees/feature-x") val activity = MutableStateFlow(mapOf( "ses_1" to SessionActivityDto(item.path, SessionActivityKindDto.ERROR), @@ -572,7 +598,7 @@ class AgentManagerPanelTest : BasePlatformTestCase() { edt { controller.reload() } flush() - assertSame(WorktreeIcons.branch, row(panel, 0).icon) + assertSame(SessionActivityKind.ERROR.icon(), row(panel, 0).icon) } fun `test idle worktree rows show the branch icon and the local row shows the monitor`() { @@ -806,10 +832,9 @@ class AgentManagerPanelTest : BasePlatformTestCase() { layout(view) edt { - val size = view.list.model.size - // Row 0 is the current (main) row; the last row is the pending create. + // Row 0 is the current (main) row; row 1 is the pending create. assertNull(view.pickable(rowCenter(view, 0))) - assertNull(view.pickable(rowCenter(view, size - 1))) + assertNull(view.pickable(rowCenter(view, 1))) } gate.complete(Unit) flush() @@ -880,6 +905,16 @@ class AgentManagerPanelTest : BasePlatformTestCase() { return edt { list.model.getElementAt(idx) as ActiveListItem } } + private fun components(root: Component): List { + val out = mutableListOf() + fun visit(item: Component) { + out += item + if (item is Container) item.components.forEach { visit(it) } + } + visit(root) + return out + } + private fun center(rect: java.awt.Rectangle) = Point(rect.x + rect.width / 2, rect.y + rect.height / 2) private fun pump() = pumpEdt() 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 e3b5c0eac16..5de1fed0fa1 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 @@ -104,6 +104,25 @@ class WorktreeControllerTest : BasePlatformTestCase() { assertEquals("feature/y", selected.last()) } + fun `test create prepends placeholder and created worktree`() { + rpc.listed += WorktreeDto("/repo/.kilo/worktrees/feature-x", "feature-x", "feature/x", "/repo/.kilo/worktrees/feature-x") + val gate = CompletableDeferred() + rpc.beforeCreate = { gate.await() } + val controller = controller() + controller.reload() + flush() + + ApplicationManager.getApplication().invokeAndWait { controller.create("feature/y", null) } + + assertEquals("feature/y", controller.model.getElementAt(0).branch) + assertTrue(controller.isPending(controller.model.getElementAt(0).id)) + gate.complete(Unit) + flush() + + assertEquals("feature/y", controller.model.getElementAt(0).branch) + assertFalse(controller.isPending(controller.model.getElementAt(0).id)) + } + fun `test create failure removes placeholder and reports the error`() { rpc.createResult = { CreateWorktreeResultDto(error = "boom") } val controller = controller() @@ -131,7 +150,7 @@ class WorktreeControllerTest : BasePlatformTestCase() { controller.reload() flush() - assertEquals(listOf("feature/x", "feature/y"), (0 until controller.model.size).map { controller.model.getElementAt(it).branch }) + assertEquals(listOf("feature/y", "feature/x"), (0 until controller.model.size).map { controller.model.getElementAt(it).branch }) assertTrue(controller.isPending(id)) gate.complete(Unit) flush() @@ -197,6 +216,25 @@ class WorktreeControllerTest : BasePlatformTestCase() { assertTrue(failures.first().locked) } + fun `test refused nested remove keeps the row and surfaces the error`() { + val item = WorktreeDto("/repo/.kilo/worktrees/feature-x", "feature-x", "feature/x", "/repo/.kilo/worktrees/feature-x") + rpc.listed += item + rpc.removeResult = { _, _, _ -> RemoveWorktreeResultDto(error = "Delete nested worktrees first:\n/repo/.kilo/worktrees/feature-x/.kilo/worktrees/nested") } + val controller = controller() + controller.reload() + flush() + + val failures = mutableListOf() + controller.remove(controller.model.getElementAt(0), onFailure = { failures.add(it) }) + flush() + + assertEquals(1, controller.model.size) + assertEquals("feature/x", controller.model.getElementAt(0).branch) + assertNull(controller.progress(item.id)) + assertEquals(listOf(false), rpc.removeForces.toList()) + assertEquals("Delete nested worktrees first:\n/repo/.kilo/worktrees/feature-x/.kilo/worktrees/nested", failures.single().error) + } + fun `test force remove passes the force flag and drops the row on success`() { val item = WorktreeDto("/repo/.kilo/worktrees/feature-x", "feature-x", "feature/x", "/repo/.kilo/worktrees/feature-x", locked = true) rpc.listed += item @@ -476,7 +514,7 @@ class WorktreeControllerTest : BasePlatformTestCase() { } } - fun `test worktree row icons show only while running or waiting`() { + fun `test worktree row icons show while running, waiting or failed`() { assertSame( WorktreeIcons.spinner, WorktreeIcons.forRow(busy = true, kind = SessionActivityKind.RUNNING), @@ -493,7 +531,10 @@ class WorktreeControllerTest : BasePlatformTestCase() { SessionActivityKind.PLAN.icon(), WorktreeIcons.forRow(busy = false, kind = SessionActivityKind.PLAN), ) - assertSame(WorktreeIcons.branch, WorktreeIcons.forRow(busy = false, kind = SessionActivityKind.ERROR)) + assertSame( + SessionActivityKind.ERROR.icon(), + WorktreeIcons.forRow(busy = false, kind = SessionActivityKind.ERROR), + ) assertSame(WorktreeIcons.branch, WorktreeIcons.forRow(busy = false, kind = null)) } 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 72a98c1dde6..37b9396f0b7 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 @@ -23,13 +23,15 @@ class WorktreeIconsTest : BasePlatformTestCase() { fun `test resting row icons carry the muted palette in both themes`() { for (name in listOf("worktreeBranch", "worktreeLock", "worktree-local")) { - // The secondary New UI greys, which are also what Label.infoForeground resolves to, so a - // resting glyph sits at the weight of the description line under it rather than the title. - val light = svg(name).replace("#818594", "GLYPH") - val dark = svg("${name}_dark").replace("#6F737A", "GLYPH") + // The tertiary New UI greys: a resting glyph only says what the checkout is, so it sits a + // step quieter than the secondary grey the description line under it uses. + val light = svg(name).replace("#A8ADBD", "GLYPH") + val dark = svg("${name}_dark").replace("#9DA0A8", "GLYPH") assertFalse("$name still uses a primary grey", light.contains("#6C707E")) assertFalse("${name}_dark still uses a primary grey", dark.contains("#CED0D6")) + assertFalse("$name still uses the secondary grey", light.contains("#818594")) + assertFalse("${name}_dark still uses the secondary grey", dark.contains("#6F737A")) // Recoloring must be the only difference: the loader animates between the two. assertEquals("$name geometry drifted from its dark variant", light, dark) } @@ -76,12 +78,13 @@ class WorktreeIconsTest : BasePlatformTestCase() { assertSame(WorktreeIcons.local, WorktreeIcons.forRow(busy = false, current = true)) } - fun `test errored session falls back to the resting glyph`() { - assertSame(WorktreeIcons.branch, WorktreeIcons.forRow(busy = false, kind = SessionActivityKind.ERROR)) - assertSame( - WorktreeIcons.local, - WorktreeIcons.forRow(busy = false, kind = SessionActivityKind.ERROR, current = true), - ) + fun `test errored session shows the error glyph over the resting one`() { + val error = SessionActivityKind.ERROR.icon() + assertSame(error, WorktreeIcons.forRow(busy = false, kind = SessionActivityKind.ERROR)) + assertSame(error, WorktreeIcons.forRow(busy = false, kind = SessionActivityKind.ERROR, current = true)) + assertSame(error, WorktreeIcons.forRow(busy = false, kind = SessionActivityKind.ERROR, locked = true)) + // An operation on the row still outranks it. + assertSame(WorktreeIcons.spinner, WorktreeIcons.forRow(busy = true, kind = SessionActivityKind.ERROR)) } fun `test activity outranks the resting glyph on the local row`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/GhBannerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/GhBannerTest.kt index 65282cabf0c..b878a7ba3ed 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/GhBannerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/GhBannerTest.kt @@ -4,6 +4,7 @@ import ai.kilocode.client.testing.FakeWorktreeRpcApi import ai.kilocode.client.testing.TestCoroutines import ai.kilocode.client.testing.pumpEdt import ai.kilocode.client.testing.TestUiTimers +import ai.kilocode.client.testing.installBrowser import ai.kilocode.client.util.edtWait import ai.kilocode.rpc.dto.GhAvailability import com.intellij.openapi.application.ApplicationManager @@ -22,6 +23,7 @@ class GhBannerTest : BasePlatformTestCase() { override fun setUp() { super.setUp() + installBrowser() coroutines = TestCoroutines() rpc = FakeWorktreeRpcApi() timers = TestUiTimers() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/GhStatusCoordinatorTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/GhStatusCoordinatorTest.kt index c5f185a231c..309614052c2 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/GhStatusCoordinatorTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/GhStatusCoordinatorTest.kt @@ -4,6 +4,7 @@ import ai.kilocode.client.testing.FakeWorktreeRpcApi import ai.kilocode.client.testing.TestCoroutines import ai.kilocode.client.testing.pumpEdt import ai.kilocode.client.testing.TestUiTimers +import ai.kilocode.client.testing.installBrowser import ai.kilocode.client.util.edtWait import ai.kilocode.rpc.dto.GhAvailability import com.intellij.openapi.application.ApplicationManager @@ -21,6 +22,7 @@ class GhStatusCoordinatorTest : BasePlatformTestCase() { override fun setUp() { super.setUp() + installBrowser() coroutines = TestCoroutines() rpc = FakeWorktreeRpcApi() timers = TestUiTimers() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeActivityTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeActivityTest.kt index 02b4292854e..5270e31c182 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeActivityTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeActivityTest.kt @@ -32,12 +32,12 @@ class WorktreeActivityTest { } @Test - fun `error outranks running but yields to interactive prompts`() { - val errorOverRunning = aggregateWorktreeActivity(mapOf( + fun `running outranks a sibling error but yields to interactive prompts`() { + val runningOverError = aggregateWorktreeActivity(mapOf( "ses_run" to SessionActivityDto("/repo/wt", SessionActivityKindDto.RUNNING), "ses_error" to SessionActivityDto("/repo/wt", SessionActivityKindDto.ERROR), )) - assertEquals(SessionActivityKind.ERROR, errorOverRunning["/repo/wt"]) + assertEquals(SessionActivityKind.RUNNING, runningOverError["/repo/wt"]) val questionOverError = aggregateWorktreeActivity(mapOf( "ses_error" to SessionActivityDto("/repo/wt", SessionActivityKindDto.ERROR), 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 9dbc014c804..53381d7858b 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 @@ -35,6 +35,8 @@ import com.intellij.openapi.ui.TestDialogManager import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.ui.OnePixelSplitter import com.intellij.ui.SearchTextField +import com.intellij.ui.SimpleColoredComponent +import com.intellij.ui.SimpleTextAttributes import com.intellij.ui.components.JBList import com.intellij.ui.components.JBScrollPane import com.intellij.util.ui.UIUtil @@ -236,6 +238,26 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() { assertEquals("new", edt { (list.selectedValue as ActiveListItem).key }) } + fun `test session row title uses regular font`() { + rpc.listed += session("ses_1", nowSeconds()) + edt { controller.reload() } + flush() + + val style = edt { + @Suppress("UNCHECKED_CAST") + val list = UIUtil.findComponentOfType(panel, JBList::class.java)!! as JBList + val row = list.model.getElementAt(0) as ActiveListItem + val comp = list.cellRenderer.getListCellRendererComponent(list, row, 0, true, true) + val title = components(comp).filterIsInstance().single() + val iter = title.iterator() + assertTrue(iter.hasNext()) + iter.next() + iter.textAttributes.style + } + + assertEquals(SimpleTextAttributes.STYLE_PLAIN, style) + } + fun `test running session row shows activity badge without leading icon`() { manager.kinds = mapOf("ses_1" to SessionActivityKind.RUNNING) val session = session("ses_1", nowSeconds()) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloSessionServiceTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloSessionServiceTest.kt index 715f1079464..dd1c2c89f54 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloSessionServiceTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloSessionServiceTest.kt @@ -1,9 +1,13 @@ package ai.kilocode.client.app +import ai.kilocode.client.session.SessionActivityKind import ai.kilocode.client.testing.FakeSessionRpcApi import ai.kilocode.client.testing.TestLog import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.SessionActivityDto +import ai.kilocode.rpc.dto.SessionActivityKindDto import ai.kilocode.rpc.dto.SessionDto +import ai.kilocode.rpc.dto.SessionStatusDto import ai.kilocode.rpc.dto.SessionTimeDto import com.intellij.testFramework.fixtures.BasePlatformTestCase import kotlinx.coroutines.CoroutineScope @@ -11,6 +15,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.toList @@ -139,6 +144,41 @@ class KiloSessionServiceTest : BasePlatformTestCase() { assertTrue(log.messages.joinToString("\n"), log.messages.any { it.contains("route=client-events stop=true failed message=stream failed") }) } + fun `test activity snapshot carries every kind the backend reports`() = runBlocking(Dispatchers.Default) { + // A busy session the backend cannot place in a directory, so only the status map has it. + rpc.statuses.value = mapOf("ses_busy" to SessionStatusDto("busy")) + rpc.activity.value = mapOf( + "ses_failed" to SessionActivityDto("/repo/wt", SessionActivityKindDto.ERROR), + "ses_asking" to SessionActivityDto("/repo/wt", SessionActivityKindDto.QUESTION), + ) + service.activity.first { it.isNotEmpty() } + + assertEquals( + mapOf( + "ses_busy" to SessionActivityKind.RUNNING, + "ses_failed" to SessionActivityKind.ERROR, + "ses_asking" to SessionActivityKind.QUESTION, + ), + service.activitySnapshot(), + ) + } + + fun `test deleting a session prunes its lingering activity and status entries`() = runBlocking(Dispatchers.Default) { + rpc.statuses.value = mapOf("ses_asking" to SessionStatusDto("busy")) + rpc.activity.value = mapOf( + "ses_asking" to SessionActivityDto("/repo/wt", SessionActivityKindDto.QUESTION), + "ses_failed" to SessionActivityDto("/repo/wt", SessionActivityKindDto.ERROR), + ) + service.activity.first { it.size == 2 } + + // The backend keeps reporting the question/error for a deleted session, so the entry must be + // pruned locally or the badge lingers on every derived surface. + service.deleteSession("ses_asking", "/repo/wt") + service.activity.first { "ses_asking" !in it } + + assertEquals(mapOf("ses_failed" to SessionActivityKind.ERROR), service.activitySnapshot()) + } + private fun session(id: String, title: String) = SessionDto( id = id, projectID = "prj", 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 4429858362e..9b2afc4400a 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 @@ -932,6 +932,22 @@ class SessionUiLayoutTest : SessionUiTestBase() { assertFalse(overlay.isVisible) } + fun `test account overlay stays hidden when prompt races empty history load`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = ProfileDto(email = "user@example.com")) + val gate = CompletableDeferred() + rpc.historyGate = gate + ui = newUi(id = "ses_test") + + ApplicationManager.getApplication().invokeAndWait { + controller().prompt("hello") + } + gate.complete(Unit) + settle() + + val overlay = find(ui) + assertFalse(overlay.isVisible) + } + fun `test non-empty explicit session does not show overlay`() { rpc.history.add(MessageWithPartsDto(message("msg1"), emptyList())) ui = newUi(id = "ses_test") diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ConnectionDelayTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ConnectionDelayTest.kt index e64333062af..8d930b60771 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ConnectionDelayTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ConnectionDelayTest.kt @@ -193,6 +193,30 @@ class ConnectionDelayTest : SessionControllerTestBase() { ) } + fun `test missing workspace status shows missing folder message`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller(displayMs = 50) + val events = collect(m) + flush() + events.clear() + + projectRpc.state.value = KiloWorkspaceStateDto( + status = KiloWorkspaceStatusDto.MISSING, + error = "/repo/.kilo/worktrees/deleted", + ) + pause(80) + + val event = events.filterIsInstance().single() + assertEquals("Workspace folder missing", event.summary) + assertEquals( + "Kilo can't load this session because the workspace folder no longer exists: /repo/.kilo/worktrees/deleted", + event.detail, + ) + assertEquals("workspace", event.source) + assertFalse(event.detail.orEmpty().contains("JetBrains Gateway")) + } + fun `test ready hides visible delayed connection banner immediately`() { appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) projectRpc.state.value = workspaceReady() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/HistoryLoadingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/HistoryLoadingTest.kt index e61bee996e2..0a3206b1012 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/HistoryLoadingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/HistoryLoadingTest.kt @@ -11,6 +11,7 @@ import ai.kilocode.rpc.dto.MessageTimeDto import ai.kilocode.rpc.dto.MessageWithPartsDto import ai.kilocode.rpc.dto.ModelDto import ai.kilocode.rpc.dto.ProviderDto +import kotlinx.coroutines.CompletableDeferred class HistoryLoadingTest : SessionControllerTestBase() { @@ -91,6 +92,25 @@ class HistoryLoadingTest : SessionControllerTestBase() { ) } + fun `test prompt during history load keeps the session view`() { + val gate = CompletableDeferred() + rpc.historyGate = gate + + val c = controller("ses_test") + val events = collect(c) + edt { c.prompt("hello") } + gate.complete(Unit) + flush() + + assertControllerEvents(""" + AccountOverlayChanged hide + AppChanged + WorkspaceChanged + ViewChanged progress + ViewChanged session + """, events) + } + fun `test loaded history derives agent from latest message`() { appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, config = ConfigDto(model = "kilo/gpt-5")) projectRpc.state.value = workspaceReady(agents = agents(), default = "plan") diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupGeometryTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupGeometryTest.kt index 2c4dec004fc..39da002f2e5 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupGeometryTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopupGeometryTest.kt @@ -13,28 +13,44 @@ class HeaderPopupGeometryTest { const val GAP = 10 const val CAP = 700 const val CAP_HEIGHT = 450 + const val INDENT = 16 } @Test - fun `chat on the left points right`() { + fun `card on the left points right`() { // Tool window on the left: the editor area to its right is the roomier side. - val spot = beside(chat = Rectangle(0, 0, 300, 1000)) + val spot = beside(card = Rectangle(0, 0, 300, 40)) assertEquals(Balloon.Position.atRight, spot.position) assertEquals(300, spot.x) } @Test - fun `chat on the right points left`() { - val spot = beside(chat = Rectangle(1700, 0, 300, 1000)) + fun `card on the right points left`() { + val spot = beside(card = Rectangle(1700, 0, 300, 40)) assertEquals(Balloon.Position.atLeft, spot.position) assertEquals(1700, spot.x) } + @Test + fun `the pointer lands on the card edge, not the session edge`() { + // Left-docked chat: cards are inset from the session, so the balloon hugs the card at 760 + // rather than docking to the session edge at 800. + val spot = HeaderPopupGeometry.beside( + pane = Rectangle(0, 0, 2000, 1000), + card = Rectangle(60, 300, 700, 40), + view = Rectangle(0, 0, 800, 1000), + fit = fit(), + ) + + assertEquals(Balloon.Position.atRight, spot.position) + assertEquals(760, spot.x) + } + @Test fun `side with more room wins even when both sides fit`() { - val spot = beside(chat = Rectangle(1200, 0, 300, 1000)) + val spot = beside(card = Rectangle(1200, 0, 300, 40)) // Left room is 1200, right room is 500. assertEquals(Balloon.Position.atLeft, spot.position) @@ -43,14 +59,14 @@ class HeaderPopupGeometryTest { @Test fun `equal room points right`() { - val spot = beside(chat = Rectangle(850, 0, 300, 1000)) + val spot = beside(card = Rectangle(850, 0, 300, 40)) assertEquals(Balloon.Position.atRight, spot.position) } @Test fun `body is capped to the free space on the chosen side`() { - val spot = beside(chat = Rectangle(0, 0, 1800, 1000)) + val spot = beside(card = Rectangle(0, 0, 1800, 40)) // 200 free on the right, minus chrome and gap. assertEquals(200 - CHROME - GAP, spot.maxWidth) @@ -58,14 +74,14 @@ class HeaderPopupGeometryTest { @Test fun `body is capped to the shared max when the side is roomy`() { - val spot = beside(chat = Rectangle(0, 0, 300, 1000)) + val spot = beside(card = Rectangle(0, 0, 300, 40)) assertEquals(CAP, spot.maxWidth) } @Test - fun `a chat filling the pane yields no room rather than a negative width`() { - val spot = beside(chat = Rectangle(0, 0, 2000, 1000)) + fun `a card filling the pane yields no room rather than a negative width`() { + val spot = beside(card = Rectangle(0, 0, 2000, 40)) assertEquals(0, spot.maxWidth) } @@ -73,19 +89,15 @@ class HeaderPopupGeometryTest { @Test fun `chrome is reserved so the balloon still fits its side`() { // The side has 400px; a body of the full 400 would overflow once the balloon adds its border, - // pointer and shadow, and an overflowing balloon gets re-pointed above or below the chat. - val spot = beside(chat = Rectangle(0, 0, 1600, 1000)) + // pointer and shadow, and an overflowing balloon gets re-pointed above or below the card. + val spot = beside(card = Rectangle(0, 0, 1600, 40)) assertTrue(spot.maxWidth + CHROME <= 400) } @Test - fun `a chat with no usable room on either side still resolves to a horizontal side`() { - val tight = HeaderPopupGeometry.beside( - pane = Rectangle(0, 0, 2000, 1000), - chat = Rectangle(0, 0, 1980, 1000), - fit = fit(), - ) + fun `a card with no usable room on either side still resolves to a horizontal side`() { + val tight = beside(card = Rectangle(0, 0, 1980, 40)) // Neither side can fit the chrome, but above/below must never be the answer. assertTrue(tight.position == Balloon.Position.atRight || tight.position == Balloon.Position.atLeft) @@ -93,39 +105,116 @@ class HeaderPopupGeometryTest { } @Test - fun `height is capped to the pane minus gaps`() { + fun `height is capped to the session minus gaps`() { val short = HeaderPopupGeometry.beside( pane = Rectangle(0, 0, 2000, 200), - chat = Rectangle(0, 0, 300, 200), + card = Rectangle(0, 0, 300, 40), + view = Rectangle(0, 0, 300, 200), fit = fit(), ) - // 200 pane, minus both gaps and the chrome the balloon reserves vertically. + // 200 session, minus both gaps and the chrome the balloon reserves vertically. assertEquals(200 - GAP * 2 - CHROME_HEIGHT, short.maxHeight) } @Test - fun `pointer target keeps a tall body inside the pane`() { - val pane = Rectangle(0, 0, 2000, 1000) + fun `height follows a short session inside a tall pane`() { + // Session in an editor tab or a short tool window: the window has room the session does not. + val spot = HeaderPopupGeometry.beside( + pane = Rectangle(0, 0, 2000, 1000), + card = Rectangle(0, 100, 300, 40), + view = Rectangle(0, 100, 300, 300), + fit = fit(), + ) - // Row near the top: target pushed down so the centred body clears the top edge. - assertEquals(310, HeaderPopupGeometry.centerY(pane, y = 20, height = 600, gap = GAP)) - // Row near the bottom: target pulled up. - assertEquals(690, HeaderPopupGeometry.centerY(pane, y = 980, height = 600, gap = GAP)) - // Row with room on both sides is left alone. - assertEquals(500, HeaderPopupGeometry.centerY(pane, y = 500, height = 600, gap = GAP)) + assertEquals(300 - GAP * 2 - CHROME_HEIGHT, spot.maxHeight) } @Test - fun `body taller than the pane is centred instead of clamped to an empty range`() { - val pane = Rectangle(0, 0, 2000, 400) + fun `height follows the session even when the card is a collapsed header`() { + val spot = beside(card = Rectangle(0, 0, 300, 30)) - assertEquals(200, HeaderPopupGeometry.centerY(pane, y = 10, height = 900, gap = GAP)) + assertEquals(CAP_HEIGHT, spot.maxHeight) } - private fun beside(chat: Rectangle) = HeaderPopupGeometry.beside( + @Test + fun `pointer stays on the row when the body already fits`() { + val aim = aim( + view = Rectangle(0, 0, 300, 1000), + card = Rectangle(0, 400, 300, 40), + y = 420, + height = 300, + ) + + assertEquals(420, aim.y) + assertEquals(150, aim.distance) + } + + @Test + fun `body shifts down while the pointer stays on the top row`() { + val view = Rectangle(0, 0, 300, 1000) + val aim = aim(view = view, card = Rectangle(0, 20, 300, 40), y = 40, height = 600) + + assertEquals(40, aim.y) + assertEquals(GAP, aim.y - aim.distance) + } + + @Test + fun `body shifts up while the pointer stays on the bottom row`() { + val view = Rectangle(0, 0, 300, 1000) + val aim = aim(view = view, card = Rectangle(0, 940, 300, 40), y = 960, height = 600) + + assertEquals(960, aim.y) + assertEquals(view.y + view.height - GAP, aim.y - aim.distance + 600) + } + + @Test + fun `pointer stays inside a collapsed card`() { + val card = Rectangle(0, 100, 300, 30) + val aim = aim(view = Rectangle(0, 0, 300, 1000), card = card, y = 115, height = 300) + + assertTrue(card.contains(0, aim.y)) + assertTrue(aim.distance in INDENT..300 - INDENT) + } + + @Test + fun `card outside the visible session falls back to the view centre`() { + val aim = aim( + view = Rectangle(0, 400, 300, 400), + card = Rectangle(0, 0, 300, 40), + y = 20, + height = 300, + ) + + assertEquals(600, aim.y) + assertEquals(150, aim.distance) + } + + @Test + fun `body taller than the session is centred instead of clamped to an empty range`() { + val view = Rectangle(0, 0, 300, 400) + val aim = aim(view = view, card = Rectangle(0, 0, 300, 40), y = 20, height = 900) + + assertEquals(20, aim.y) + assertEquals(-250, aim.y - aim.distance) + assertTrue(aim.distance in INDENT..900 - INDENT) + } + + @Test + fun `pointer distance stays in the platform legal window`() { + listOf( + aim(view = Rectangle(0, 0, 300, 200), card = Rectangle(0, 0, 300, 30), y = 15, height = 160) to 160, + aim(view = Rectangle(0, 0, 300, 200), card = Rectangle(0, 170, 300, 30), y = 185, height = 160) to 160, + aim(view = Rectangle(0, 0, 300, 200), card = Rectangle(0, 80, 300, 40), y = 100, height = 500) to 500, + ).forEach { pair -> + assertTrue(pair.first.distance in INDENT..pair.second - INDENT) + } + } + + private fun beside(card: Rectangle) = HeaderPopupGeometry.beside( pane = Rectangle(0, 0, 2000, 1000), - chat = chat, + card = card, + view = Rectangle(0, 0, 2000, 1000), fit = fit(), ) @@ -136,4 +225,14 @@ class HeaderPopupGeometryTest { maxWidth = CAP, maxHeight = CAP_HEIGHT, ) + + private fun aim(view: Rectangle, card: Rectangle, y: Int, height: Int) = HeaderPopupGeometry.aim( + view = view, + card = card, + y = y, + height = height, + gap = GAP, + indent = INDENT, + ) + } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartViewTest.kt index 8f1004f729e..b5b80a333cb 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartViewTest.kt @@ -13,7 +13,9 @@ import java.awt.image.BufferedImage import javax.swing.Icon import javax.swing.JComponent import javax.swing.JLabel +import javax.swing.JLayeredPane import javax.swing.JPanel +import javax.swing.JRootPane @Suppress("UnstableApiUsage") class AbstractSessionPartViewTest : BasePlatformTestCase() { @@ -193,6 +195,45 @@ class AbstractSessionPartViewTest : BasePlatformTestCase() { assertEquals(SessionUiStyle.View.Surface.headerBgColor().rgb, row.background.rgb) } + fun `test hover survives an exit that stays on the row`() { + val view = NestedView(JLabel("link")) + val row = view.component(0) as JPanel + pane(view) + + enter(row) + // Swing reports an exit for every nested crossing; one that lands back on the row is not a + // leave, so the fill must stay. + exit(row, 5, 5) + + assertEquals(SessionUiStyle.View.Surface.headerHoverBgColor().rgb, row.background.rgb) + } + + fun `test hover clears when an overlay covers the row under the pointer`() { + val view = NestedView(JLabel("link")) + val row = view.component(0) as JPanel + val pane = pane(view) + enter(row) + assertEquals(SessionUiStyle.View.Surface.headerHoverBgColor().rgb, row.background.rgb) + + // A banner painted above the transcript owns the pointer even while it sits inside the row's + // bounds, so the row must not stay lit underneath it. + pane.add(JPanel().apply { setBounds(0, 0, 200, 40) }, JLayeredPane.PALETTE_LAYER) + exit(row, 5, 5) + + assertEquals(SessionUiStyle.View.Surface.headerBgColor().rgb, row.background.rgb) + } + + private fun pane(view: AbstractSessionPartView): JLayeredPane { + val root = JRootPane() + root.setSize(200, 40) + root.contentPane.add(view) + view.setSize(200, 40) + view.doLayout() + root.doLayout() + root.contentPane.doLayout() + return root.layeredPane + } + fun `test clicking a nested header child toggles the card`() { val child = JLabel("plain") val header = JPanel(BorderLayout()).apply { add(child, BorderLayout.WEST) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt index 2837e0e58d0..1e17cce1bca 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt @@ -19,6 +19,7 @@ import ai.kilocode.client.ui.list.ActiveListRenderer import ai.kilocode.client.ui.list.ActiveListRowHeight import ai.kilocode.client.ui.list.ActiveListSelection import ai.kilocode.client.ui.list.ActiveListView +import ai.kilocode.client.ui.list.ActiveListWeight import ai.kilocode.client.ui.list.ACTIVE_LIST_CHANGES_CELL import ai.kilocode.client.ui.list.ACTIVE_LIST_MENU_CELL import ai.kilocode.client.ui.list.ACTIVE_LIST_PR_CELL @@ -30,6 +31,7 @@ import com.intellij.openapi.actionSystem.DefaultActionGroup import com.intellij.openapi.application.ApplicationManager import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.ui.CollectionListModel +import com.intellij.ui.GroupHeaderSeparator import com.intellij.ui.ScrollingUtil import com.intellij.ui.SimpleColoredComponent import com.intellij.ui.SimpleTextAttributes @@ -44,6 +46,7 @@ import java.awt.Dimension import java.awt.Point import java.awt.event.InputEvent import java.awt.event.MouseEvent +import java.awt.image.BufferedImage import javax.swing.JLayeredPane import javax.swing.JPanel import javax.swing.ListSelectionModel @@ -217,6 +220,61 @@ class SettingsListViewTest : BasePlatformTestCase() { } } + fun `test renderer draws the row title in plain weight when configured`() { + edt { + val row = item("with", "Alpha", "Description") + val model = CollectionListModel(listOf(row)) + val list = JBList(model) + val renderer = ActiveListRenderer(model, ActiveListConfig.Equal.copy(title = ActiveListWeight.PLAIN)) + + renderer.getListCellRendererComponent(list, row, 0, true, true) + + val title = components(renderer).filterIsInstance().single() + val iter = title.iterator() + iter.next() + assertEquals(SimpleTextAttributes.STYLE_PLAIN, iter.textAttributes.style) + assertEquals("Alpha", iter.fragment) + } + } + + fun `test renderer styles section header weight from config`() { + edt { + val first = sectionItem("one", "Alpha", "Local") + val second = sectionItem("two", "Beta", "Remote") + val model = CollectionListModel(listOf(first, second)) + val list = JBList(model) + val bold = ActiveListRenderer(model, ActiveListConfig.Equal) + val plain = ActiveListRenderer(model, ActiveListConfig.Equal.copy(header = ActiveListWeight.PLAIN)) + + bold.getListCellRendererComponent(list, second, 1, false, false) + plain.getListCellRendererComponent(list, second, 1, false, false) + + assertTrue(components(bold).filterIsInstance().single().font.isBold) + assertFalse(components(plain).filterIsInstance().single().font.isBold) + } + } + + fun `test renderer reads section divider visibility from config`() { + edt { + val first = sectionItem("one", "Alpha", "Local") + val second = sectionItem("two", "Beta", "Remote") + val model = CollectionListModel(listOf(first, second)) + val list = JBList(model) + val divider = ActiveListRenderer(model, ActiveListConfig.Equal) + val none = ActiveListRenderer(model, ActiveListConfig.Equal.copy(divider = false)) + + divider.getListCellRendererComponent(list, first, 0, false, false) + assertTrue(components(divider).filterIsInstance().single().isHideLine) + divider.getListCellRendererComponent(list, second, 1, false, false) + assertFalse(components(divider).filterIsInstance().single().isHideLine) + + none.getListCellRendererComponent(list, first, 0, false, false) + assertTrue(components(none).filterIsInstance().single().isHideLine) + none.getListCellRendererComponent(list, second, 1, false, false) + assertTrue(components(none).filterIsInstance().single().isHideLine) + } + } + fun `test narrow row squeezes title but keeps tags full width`() { edt { val row = object : ActiveListItem { @@ -254,7 +312,7 @@ class SettingsListViewTest : BasePlatformTestCase() { val list = JBList(model) val renderer = ActiveListRenderer(model, ActiveListConfig.Equal) - renderer.getListCellRendererComponent(list, row, 0, true, true) + renderer.getListCellRendererComponent(list, row, 0, false, false) renderer.setSize(320, renderer.preferredSize.height) layout(renderer) @@ -264,6 +322,47 @@ class SettingsListViewTest : BasePlatformTestCase() { } } + fun `test renderer recolors a tinted leading icon to the foreground on selection`() { + edt { + val row = object : ActiveListItem { + override val key = "with" + override val title = "Alpha" + override val icon = AllIcons.Nodes.Plugin + override val tinted = true + } + val model = CollectionListModel(listOf(row)) + val list = JBList(model) + val renderer = ActiveListRenderer(model, ActiveListConfig.Equal) + + renderer.getListCellRendererComponent(list, row, 0, false, false) + val mark = components(renderer).filterIsInstance().single { it.icon === AllIcons.Nodes.Plugin } + renderer.getListCellRendererComponent(list, row, 0, true, true) + + // At rest the row keeps the icon's own theme color; a focused selection swaps in a + // foreground-tinted copy so the glyph matches the highlighted title. + assertNotSame(AllIcons.Nodes.Plugin, mark.icon) + } + } + + fun `test renderer keeps an untinted colored icon on selection`() { + edt { + val row = object : ActiveListItem { + override val key = "with" + override val title = "Alpha" + override val icon = AllIcons.Nodes.Plugin + } + val model = CollectionListModel(listOf(row)) + val list = JBList(model) + val renderer = ActiveListRenderer(model, ActiveListConfig.Equal) + + renderer.getListCellRendererComponent(list, row, 0, true, true) + + // Colored status glyphs (running, question, error) opt out and keep their own hue: the + // leading label still holds the original icon by identity after a focused selection. + assertNotNull(components(renderer).filterIsInstance().single { it.icon === AllIcons.Nodes.Plugin }) + } + } + fun `test renderer shows optional trailing text`() { edt { val with = object : ActiveListItem { @@ -1016,6 +1115,60 @@ class SettingsListViewTest : BasePlatformTestCase() { } } + fun `test pr badge hit region ignores the metrics of other rows`() { + edt { + val calls = mutableListOf() + val view = ActiveListView("Empty") { _, _ -> } + view.update( + listOf( + metricsItem( + "wide", + "Alpha", + ActiveListMetrics( + additions = 1234, + deletions = 987, + ahead = 42, + behind = 17, + pr = ActiveListBadge("#12345"), + onPr = { calls += "wide" }, + ), + ), + metricsItem("narrow", "Beta", ActiveListMetrics(pr = ActiveListBadge("#7"), onPr = { calls += "narrow" })), + ), + ) + view.list.size = Dimension(360, 160) + view.list.doLayout() + UIUtil.dispatchAllInvocationEvents() + + val wide = activeListCellBounds(view.list, 0, selected = false).getValue(ACTIVE_LIST_PR_CELL) + val narrow = activeListCellBounds(view.list, 1, selected = false).getValue(ACTIVE_LIST_PR_CELL) + // Both badges trail their row, so they share a right edge no matter how wide the changes + // beside them are. + assertEquals(wide.x + wide.width, narrow.x + narrow.width) + + // The renderer is one reused stamp: rendering the wide row, or a full paint pass over + // every row, must not move the narrow row's hit region. + activeListCellBounds(view.list, 0, selected = false) + assertEquals(narrow, activeListCellBounds(view.list, 1, selected = false).getValue(ACTIVE_LIST_PR_CELL)) + paint(view.list) + assertEquals(narrow, activeListCellBounds(view.list, 1, selected = false).getValue(ACTIVE_LIST_PR_CELL)) + + click(view, center(narrow)) + click(view, center(wide)) + assertEquals(listOf("narrow", "wide"), calls) + } + } + + private fun paint(list: JBList<*>) { + val image = UIUtil.createImage(list, list.width, list.height, BufferedImage.TYPE_INT_ARGB) + val g = image.createGraphics() + try { + list.paint(g) + } finally { + g.dispose() + } + } + private fun item(id: String, name: String, note: String?, vararg cells: ActiveListCell) = object : ActiveListItem { override val key = id override val title = name diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUiTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUiTest.kt index 69911d94967..aed6ce37bca 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUiTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUiTest.kt @@ -4,6 +4,7 @@ import ai.kilocode.client.util.edtWait import ai.kilocode.client.app.KiloProviderService import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.testing.FakeProviderRpcApi +import ai.kilocode.client.testing.installBrowser import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.list.ActiveListActionCell import ai.kilocode.client.ui.list.ActiveListConfig @@ -985,6 +986,7 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() { fun `test provider oauth auto response shows device auth panel`() { val callback = CompletableDeferred() + val browser = installBrowser() val rpc = installProvider( ProviderSettingsDto( providers = listOf(provider("openai", "OpenAI")), @@ -1016,6 +1018,7 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() { assertTrue(t, t.contains("Open Browser")) assertTrue(t, t.contains("Cancel")) assertEquals("https://auth.openai.com/device", fieldsByName(panel, "kilo.provider.oauth.url").single().text) + assertEquals(listOf("https://auth.openai.com/device"), browser.urls) val qr = components(panel).filterIsInstance().single { it.name == "kilo.provider.oauth.qr" } assertNotNull(qr.icon) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeBrowserLauncher.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeBrowserLauncher.kt new file mode 100644 index 00000000000..8870f98427b --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeBrowserLauncher.kt @@ -0,0 +1,37 @@ +package ai.kilocode.client.testing + +import com.intellij.ide.browsers.BrowserLauncher +import com.intellij.ide.browsers.WebBrowser +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.Project +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.testFramework.replaceService +import java.nio.file.Path + +class FakeBrowserLauncher : BrowserLauncher() { + val urls = mutableListOf() + val files = mutableListOf() + + override fun open(url: String) { + urls.add(url) + } + + @Suppress("DEPRECATION") + override fun browse(file: java.io.File) { + files.add(file.toPath()) + } + + override fun browse(file: Path) { + files.add(file) + } + + override fun browse(url: String, browser: WebBrowser?, project: Project?) { + urls.add(url) + } +} + +fun BasePlatformTestCase.installBrowser(): FakeBrowserLauncher { + val fake = FakeBrowserLauncher() + ApplicationManager.getApplication().replaceService(BrowserLauncher::class.java, fake, testRootDisposable) + return fake +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/LayeredOverlayPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/LayeredOverlayPanelTest.kt index ef4f95f1c02..8867dbbc0e4 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/LayeredOverlayPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/LayeredOverlayPanelTest.kt @@ -3,7 +3,10 @@ package ai.kilocode.client.ui import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.util.ui.components.BorderLayoutPanel import java.awt.Dimension +import java.awt.Point import java.awt.Rectangle +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent import javax.swing.JLayeredPane @Suppress("UnstableApiUsage") @@ -102,6 +105,72 @@ class LayeredOverlayPanelTest : BasePlatformTestCase() { assertTrue(root.blocker.contains(50, 50)) } + fun `test a blocking overlay releases the hover of the content it covers`() { + val root = LayeredOverlayPanel().apply { setSize(400, 260) } + val hovered = Hovered() + root.content.add(hovered) + root.addOverlay(Probe(), blocks = true) { _, item -> Rectangle(0, 0, item.preferredSize.width, item.preferredSize.height) } + root.doLayout() + + root.releaseHover(Point(20, 10)) + + assertEquals(1, hovered.exits) + } + + fun `test content keeps its hover where no blocking overlay covers it`() { + val root = LayeredOverlayPanel().apply { setSize(400, 260) } + val hovered = Hovered() + root.content.add(hovered) + root.addOverlay(Probe(), blocks = true) { _, item -> Rectangle(0, 0, item.preferredSize.width, item.preferredSize.height) } + root.doLayout() + + root.releaseHover(Point(200, 200)) + + assertEquals(0, hovered.exits) + } + + fun `test a decorating overlay leaves the hover of the content below alone`() { + val root = LayeredOverlayPanel().apply { setSize(400, 260) } + val hovered = Hovered() + root.content.add(hovered) + // A hover affordance drawn for the row it sits on must not take that row's hover away. + root.addOverlay(Probe()) { _, item -> Rectangle(0, 0, item.preferredSize.width, item.preferredSize.height) } + root.doLayout() + + root.releaseHover(Point(20, 10)) + + assertEquals(0, hovered.exits) + } + + fun `test the blocker releases the hover of the content under the pointer`() { + val root = LayeredOverlayPanel().apply { setSize(400, 260) } + val hovered = Hovered() + root.content.add(hovered) + root.doLayout() + + root.releaseHover(Point(20, 10)) + assertEquals(0, hovered.exits) + + root.setBlocked(true) + root.releaseHover(Point(20, 10)) + + assertEquals(1, hovered.exits) + } + + private class Hovered : BorderLayoutPanel() { + var exits = 0 + private set + + init { + setBounds(0, 0, 400, 260) + addMouseListener(object : MouseAdapter() { + override fun mouseExited(e: MouseEvent) { + exits++ + } + }) + } + } + private class Probe : BorderLayoutPanel() { var laid = false diff --git a/packages/kilo-jetbrains/gradle.properties b/packages/kilo-jetbrains/gradle.properties index 033219d8d8f..df9565b0042 100644 --- a/packages/kilo-jetbrains/gradle.properties +++ b/packages/kilo-jetbrains/gradle.properties @@ -1,5 +1,5 @@ kotlin.stdlib.default.dependency=false -kilo.jetbrains.version=7.1.0-rc.3 +kilo.jetbrains.version=7.1.0-rc.4 # When true (default) the JetBrains plugin uses the pinned CLI release from package.json. # Set to false ONLY for local dev: generate the client from local source + bundle the local binary. # false is NOT releasable -- production builds fail unless this is true. diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloWorkspaceStateDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloWorkspaceStateDto.kt index 487951a1121..aaf238e2f60 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloWorkspaceStateDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloWorkspaceStateDto.kt @@ -8,6 +8,7 @@ enum class KiloWorkspaceStatusDto { LOADING, READY, UNSUPPORTED, + MISSING, ERROR, } 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 b3a98d6d587..54ee736293d 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 @@ -11,6 +11,7 @@ data class WorktreeDto( val main: Boolean = false, // primary working tree — not deletable val locked: Boolean = false, // git worktree lock — blocks a plain remove val lockReason: String? = null, // optional reason recorded when the tree was locked + val prunable: Boolean = false, // git marks metadata stale because the directory is gone ) @Serializable diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 6112de0eb40..206972ce9e6 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -163,7 +163,7 @@ import type { StoredProviderKey } from "./provider-actions" import { AnacondaDesktopBridge } from "./anaconda-desktop/bridge" import { fetchOpenAIModels, FetchModelsError } from "./shared/fetch-models" import type { Agent } from "@kilocode/sdk/v2/client" -import { configFeatures } from "./features" +import { configFeatures, serverFeatures } from "./features" import { fetchSnapshot } from "./kilo-provider/config-snapshot" import { createAutoApproveBridge } from "./kilo-provider/auto-approve" import type { KiloProviderOptions } from "./kilo-provider/options" @@ -3394,6 +3394,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper const global = snapshot.targets.global.raw as Config const projectConfig = bindings.project ? (snapshot.targets.project.raw as Config) : undefined this.cachedGlobalConfig = global + const features = configFeatures(snapshot.effective, await serverFeatures(this.client, dir)) this.cachedConfigMessage = { type: "configLoaded", config: snapshot.effective, @@ -3401,7 +3402,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper projectConfig, bindings, settings: this.configSettings(), - features: configFeatures(snapshot.effective), + features, } this.postMessage({ type: "configUpdated", @@ -3410,7 +3411,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper projectConfig, bindings, settings: this.configSettings(), - features: configFeatures(snapshot.effective), + features, }) await Promise.all([ refreshProviders ? this.fetchAndSendProviders() : Promise.resolve(), @@ -4126,7 +4127,15 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.cancelRetry(sid) const client = this.client if (!client) return Promise.resolve(false) - return this.aborts.stop(client, sid, this.getWorkspaceDirectory(sid)) + const directory = this.getWorkspaceDirectory(sid) + const dirs = this.aborts.directories(sid, directory) + const ids = new Map(dirs.map((dir) => [dir, this.connectionService.beginExplicitAbort(sid, dir)])) + return this.aborts.stop(client, sid, directory, dirs).then((result) => { + for (const attempt of result.attempts) { + this.connectionService.finishExplicitAbort(sid, attempt.dir, ids.get(attempt.dir)!, attempt.aborted) + } + return result.complete + }) } private async handleAbort(sessionID?: string): Promise { @@ -4134,7 +4143,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper if (!sid || !(await this.stopSession(sid))) return this.sessionStatusMap.set(sid, "idle") this.streams.flush(sid) - this.postMessage({ type: "sessionTurnClosed", sessionID: sid, reason: "interrupted" }) this.postMessage({ type: "sessionStatus", sessionID: sid, status: "idle" }) } diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 4ab0529942b..6fd983ae94a 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -213,7 +213,7 @@ export class AgentManagerProvider implements Disposable { log: (msg) => this.log(msg), }) const local = createLocalDiff(this.gitOps, (...args) => this.log(...args)) - this.diffCatalog = new DiffSourceCatalog(this.connectionService) + this.diffCatalog = new DiffSourceCatalog(this.connectionService, local) this.diffs = new WorktreeDiffController({ getState: () => this.getStateManager(), getRoot: () => this.getRoot(), @@ -1804,7 +1804,7 @@ export class AgentManagerProvider implements Disposable { await continueInWorktree( { root, - getClient: () => this.connectionService.getClient(), + connection: this.connectionService, createWorktreeOnDisk: (opts) => this.createWorktreeOnDisk(opts), runSetupScript: (p, b, id) => this.runSetupScriptForWorktree(p, b, id), cleanupWorktree: async (id) => { diff --git a/packages/kilo-vscode/src/agent-manager/GitOps.ts b/packages/kilo-vscode/src/agent-manager/GitOps.ts index 8b14d972773..e31ebc00c7e 100644 --- a/packages/kilo-vscode/src/agent-manager/GitOps.ts +++ b/packages/kilo-vscode/src/agent-manager/GitOps.ts @@ -44,6 +44,7 @@ interface ExecOptions { env?: NodeJS.ProcessEnv stdin?: string timeout?: number + signal?: AbortSignal } export interface ExecResult { @@ -594,12 +595,12 @@ export class GitOps { * suitable for callers that need to tolerate legitimate failures (e.g. * `merge-base` on an orphan branch, `ls-files --error-unmatch`). */ - execGit(args: string[], cwd: string, options?: { stdin?: string }): Promise { + execGit(args: string[], cwd: string, options?: { stdin?: string; signal?: AbortSignal }): Promise { return this.exec(args, cwd, options) } - execGitBuffer(args: string[], cwd: string): Promise { - return this.execBuffer(args, cwd) + execGitBuffer(args: string[], cwd: string, options?: { signal?: AbortSignal }): Promise { + return this.execBuffer(args, cwd, options) } private async exec(args: string[], cwd: string, options?: ExecOptions): Promise { @@ -616,7 +617,7 @@ export class GitOps { return { code: 1, stdout: Buffer.alloc(0), stderr: "GitOps disposed" } } const invoke = () => this.invoke(cmd, args, cwd, options) - return this.semaphore ? this.semaphore.run(invoke) : invoke() + return this.semaphore ? this.semaphore.run(invoke, options?.signal) : invoke() } private executable(): Promise { @@ -642,7 +643,7 @@ export class GitOps { } private invoke(cmd: string, args: string[], cwd: string, options?: ExecOptions): Promise { - if (this.controller.signal.aborted) { + if (this.controller.signal.aborted || options?.signal?.aborted) { return Promise.resolve({ code: 1, stdout: Buffer.alloc(0), stderr: "GitOps disposed" }) } @@ -664,6 +665,7 @@ export class GitOps { : undefined this.controller.signal.addEventListener("abort", abort, { once: true }) + options?.signal?.addEventListener("abort", abort, { once: true }) child.stdout?.on("data", (chunk: Buffer) => out.push(chunk)) child.stderr?.on("data", (chunk: Buffer) => err.push(chunk)) @@ -673,6 +675,7 @@ export class GitOps { child.on("close", (code) => { if (timeout) clearTimeout(timeout) this.controller.signal.removeEventListener("abort", abort) + options?.signal?.removeEventListener("abort", abort) resolve({ code: code ?? 1, stdout: Buffer.concat(out), diff --git a/packages/kilo-vscode/src/agent-manager/continue-in-worktree.ts b/packages/kilo-vscode/src/agent-manager/continue-in-worktree.ts index 77a51d50311..676a8cbfa61 100644 --- a/packages/kilo-vscode/src/agent-manager/continue-in-worktree.ts +++ b/packages/kilo-vscode/src/agent-manager/continue-in-worktree.ts @@ -8,7 +8,10 @@ import { recordForkHandoff } from "./fork-handoff" export interface ContinueContext { root: string - getClient: () => KiloClient + connection: { + getClient: () => KiloClient + runExplicitAbort: (sessionId: string, directory: string, action: () => Promise) => Promise + } createWorktreeOnDisk: (opts: { baseBranch: string; baseRef: string }) => Promise<{ worktree: { id: string } result: CreateWorktreeResult @@ -30,10 +33,13 @@ export type StepResult = { ok: true; value: T } | { ok: false; error: string /** Abort a running session. Best-effort — failures are logged but not fatal. */ export async function abortSession(ctx: ContinueContext, sessionId: string): Promise { try { - const client = ctx.getClient() - await client.session.abort({ sessionID: sessionId }).catch((err) => { - ctx.log("Session abort failed (may already be idle):", getErrorMessage(err)) - }) + await ctx.connection + .runExplicitAbort(sessionId, ctx.root, async () => { + await ctx.connection.getClient().session.abort({ sessionID: sessionId }, { throwOnError: true }) + }) + .catch((err) => { + ctx.log("Session abort failed (may already be idle):", getErrorMessage(err)) + }) } catch (err) { ctx.log("Client not available for abort, continuing:", getErrorMessage(err)) } @@ -96,7 +102,7 @@ async function rollback( export async function forkSession(ctx: ContinueContext, sessionId: string, dir: string): Promise> { let client: KiloClient try { - client = ctx.getClient() + client = ctx.connection.getClient() } catch (err) { ctx.log("Client not available for session fork:", getErrorMessage(err)) return { ok: false, error: "Not connected to CLI backend" } diff --git a/packages/kilo-vscode/src/agent-manager/local-diff.ts b/packages/kilo-vscode/src/agent-manager/local-diff.ts index e643e866270..c142bef0ee4 100644 --- a/packages/kilo-vscode/src/agent-manager/local-diff.ts +++ b/packages/kilo-vscode/src/agent-manager/local-diff.ts @@ -117,26 +117,29 @@ async function ancestor(git: GitOps, dir: string, base: string, log?: Log): Prom return result.stdout.trim() } -async function numstat(git: GitOps, dir: string, base: string, file?: string) { - const args = ["-c", "core.quotepath=false", "diff", "--numstat", "--no-renames", base] - if (file) args.push("--", file) - const result = await git.execGit(args, dir) - const map = new Map() - if (result.code !== 0) return map - for (const line of result.stdout.trim().split("\n")) { - if (!line) continue +function counts(value: string) { + const result = new Map() + for (const line of value.trim().split("\n")) { + if (!line || line.startsWith(":")) continue const parts = line.split("\t") const add = parts[0] const del = parts[1] - const name = parts.slice(2).join("\t") - if (!name) continue - map.set(name, { + const file = parts.slice(2).join("\t") + if (!file) continue + result.set(file, { additions: add === "-" ? 0 : parseInt(add || "0", 10) || 0, deletions: del === "-" ? 0 : parseInt(del || "0", 10) || 0, binary: add === "-" || del === "-", }) } - return map + return result +} + +async function numstat(git: GitOps, dir: string, base: string, file?: string) { + const args = ["-c", "core.quotepath=false", "diff", "--numstat", "--no-renames", base] + if (file) args.push("--", file) + const result = await git.execGit(args, dir) + return counts(result.code === 0 ? result.stdout : "") } async function statStamp(dir: string, file: string): Promise { @@ -144,7 +147,22 @@ async function statStamp(dir: string, file: string): Promise { if (!full) return `missing:${file}` const stat = await fs.lstat(full).catch(() => undefined) if (!stat) return `missing:${file}` - return `${stat.size}:${stat.mtimeMs}` + return `${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}:${stat.ino ?? 0}` +} + +async function detailReads(git: GitOps, dir: string, anc: string, meta: Meta, signal?: AbortSignal) { + return Promise.all([ + readBefore(git, dir, anc, meta.file, meta.status, signal), + readAfter(dir, meta.file, meta.status), + meta.tracked ? unifiedPatch(git, dir, anc, meta.file, signal) : Promise.resolve(""), + ]) +} + +async function sizes(git: GitOps, dir: string, anc: string, meta: Meta, signal?: AbortSignal) { + return Promise.all([ + meta.status === "added" ? 0 : blobSize(git, dir, anc, meta.file, signal), + meta.status === "deleted" ? 0 : fileSize(dir, meta.file), + ]) } async function lineCount(file: string): Promise { @@ -166,28 +184,28 @@ function statusFromCode(code: string): Status { } async function list(git: GitOps, dir: string, anc: string, log?: Log): Promise { - const nameStatus = await git.execGit( - ["-c", "core.quotepath=false", "diff", "--name-status", "--no-renames", anc], - dir, - ) - if (nameStatus.code !== 0) { - log?.("git diff --name-status failed", { code: nameStatus.code, stderr: nameStatus.stderr.trim() }) + const [tracked, untracked] = await Promise.all([ + git.execGit(["-c", "core.quotepath=false", "diff", "--raw", "--numstat", "--no-renames", anc], dir), + git.execGit(["ls-files", "--others", "--exclude-standard"], dir), + ]) + if (tracked.code !== 0) { + log?.("git diff --raw --numstat failed", { code: tracked.code, stderr: tracked.stderr.trim() }) return [] } - const counts = await numstat(git, dir, anc) const result: Meta[] = [] const seen = new Set() + const stats = counts(tracked.stdout) - for (const line of nameStatus.stdout.trim().split("\n")) { - if (!line) continue + for (const line of tracked.stdout.trim().split("\n")) { + if (!line.startsWith(":")) continue const parts = line.split("\t") - const code = parts[0] + const code = parts[0]?.split(" ").at(-1) const file = parts.slice(1).join("\t") if (!file || !code) continue seen.add(file) const status = statusFromCode(code) - const stat = counts.get(file) ?? { additions: 0, deletions: 0, binary: false } + const stat = stats.get(file) ?? { additions: 0, deletions: 0, binary: false } result.push({ file, additions: stat.additions, @@ -201,7 +219,6 @@ async function list(git: GitOps, dir: string, anc: string, log?: Log): Promise }>() + const generations = new Map() + const details = new Map() + const pending = new Map }>() + let bytes = 0 + + const forget = (id: string) => { + const value = details.get(id) + if (!value) return + bytes -= value.bytes + details.delete(id) + } + + const remember = (id: string, value: WorktreeDiffEntry, stamp: string) => { + const size = [value.before, value.after, value.patch, value.image?.before?.data, value.image?.after?.data].reduce( + (sum, value) => sum + Buffer.byteLength(value ?? ""), + 0, + ) + const current = details.get(id) + if (current) bytes -= current.bytes + details.delete(id) + details.set(id, { value, bytes: size, stamp }) + bytes += size + while (details.size > 128 || bytes > 64 * 1024 * 1024) { + const key = details.keys().next().value! + bytes -= details.get(key)!.bytes + details.delete(key) + } + } return { summary: async (dir: string, base: string): Promise => { const id = `${dir}\0${base}` + const generation = (generations.get(id) ?? 0) + 1 + generations.set(id, generation) const anc = await ancestor(git, dir, base, log) if (!anc) { - states.delete(id) + if (generations.get(id) === generation) states.delete(id) return [] } const items = await list(git, dir, anc, log) + if (generations.get(id) !== generation) return items.map(summarize) states.delete(id) states.set(id, { anc, metas: new Map(items.map((item) => [item.file, item])) }) if (states.size > 8) states.delete(states.keys().next().value!) return items.map(summarize) }, - file: async (dir: string, base: string, file: string): Promise => { + file: async (dir: string, base: string, file: string, signal?: AbortSignal): Promise => { const state = states.get(`${dir}\0${base}`) if (!state) return diffFile(git, dir, base, file, log) const meta = state.metas.get(file) if (!meta) return null - return materialize(git, dir, state.anc, meta, log) + const id = `${dir}\0${base}\0${state.anc}\0${file}\0${meta.tracked}\0${meta.status}\0${meta.additions}\0${meta.deletions}\0${meta.binary}\0${meta.stamp}` + const cached = details.get(id) + if (cached) { + if (cached.stamp === meta.stamp) { + remember(id, cached.value, meta.stamp) + return cached.value + } + forget(id) + } + const current = pending.get(id) + if (current && !current.signal?.aborted) return current.work + const work = materialize(git, dir, state.anc, meta, log, signal) + pending.set(id, { signal, work }) + work.then( + (value) => { + if (pending.get(id)?.work !== work) return + pending.delete(id) + if (value.image?.before?.error === "unreadable" || value.image?.after?.error === "unreadable") return + remember(id, value, meta.stamp) + }, + () => { + if (pending.get(id)?.work === work) pending.delete(id) + }, + ) + return work }, } } @@ -343,9 +415,9 @@ async function detailMeta(git: GitOps, dir: string, anc: string, file: string): } } -async function blobSize(git: GitOps, dir: string, anc: string, file: string): Promise { - const result = await git.execGit(["cat-file", "-s", `${anc}:${file}`], dir) - if (result.code !== 0) return 0 +async function blobSize(git: GitOps, dir: string, anc: string, file: string, signal?: AbortSignal): Promise { + const result = await git.execGit(["cat-file", "-s", `${anc}:${file}`], dir, { signal }) + if (result.code !== 0) throw new Error(`Could not read base blob for ${file}`) return parseInt(result.stdout.trim(), 10) || 0 } @@ -356,8 +428,14 @@ async function fileSize(dir: string, file: string): Promise { return stat?.size ?? 0 } -async function readBlob(git: GitOps, dir: string, ref: string, file: string): Promise { - const result = await git.execGitBuffer(["show", `${ref}:${file}`], dir) +async function readBlob( + git: GitOps, + dir: string, + ref: string, + file: string, + signal?: AbortSignal, +): Promise { + const result = await git.execGitBuffer(["show", `${ref}:${file}`], dir, { signal }) return result.code === 0 ? result.stdout : undefined } @@ -369,29 +447,47 @@ async function readFile(dir: string, file: string): Promise return readImageFile(full) } -async function readBefore(git: GitOps, dir: string, anc: string, file: string, status: Status): Promise { +async function readBefore( + git: GitOps, + dir: string, + anc: string, + file: string, + status: Status, + signal?: AbortSignal, +): Promise { if (status === "added") return "" - const result = await git.execGit(["show", `${anc}:${file}`], dir) - return result.code === 0 ? result.stdout : "" + const result = await git.execGit(["show", `${anc}:${file}`], dir, { signal }) + if (result.code !== 0) throw new Error(`Could not read base file for ${file}`) + return result.stdout } async function readAfter(dir: string, file: string, status: Status): Promise { if (status === "deleted") return "" const full = resolveInside(dir, file) - if (!full) return "" + if (!full) throw new Error(`Could not resolve working file for ${file}`) const stat = await fs.lstat(full).catch(() => undefined) - if (!stat) return "" + if (!stat) throw new Error(`Could not read working file for ${file}`) if (stat.isSymbolicLink()) return fs.readlink(full).catch(() => "") - if (!stat.isFile()) return "" - return fs.readFile(full, "utf-8").catch(() => "") + if (!stat.isFile()) throw new Error(`Working path is not a file: ${file}`) + return fs.readFile(full, "utf-8").catch(() => { + throw new Error(`Could not read working file for ${file}`) + }) } -async function unifiedPatch(git: GitOps, dir: string, anc: string, file: string): Promise { +async function unifiedPatch( + git: GitOps, + dir: string, + anc: string, + file: string, + signal?: AbortSignal, +): Promise { const result = await git.execGit( ["-c", "core.quotepath=false", "diff", "--no-ext-diff", "--no-renames", anc, "--", file], dir, + { signal }, ) - return result.code === 0 ? result.stdout : "" + if (result.code !== 0) throw new Error(`Could not create diff for ${file}`) + return result.stdout } function linesOf(text: string): number { @@ -418,17 +514,27 @@ export async function diffFile( return materialize(git, dir, anc, meta, log) } -async function materialize(git: GitOps, dir: string, anc: string, meta: Meta, log?: Log): Promise { +async function materialize( + git: GitOps, + dir: string, + anc: string, + meta: Meta, + log?: Log, + signal?: AbortSignal, +): Promise { const mime = imageMime(meta.file) if (meta.binary && !mime) return summarize(meta) - const beforeBytes = meta.status === "added" ? 0 : await blobSize(git, dir, anc, meta.file) - const afterBytes = meta.status === "deleted" ? 0 : await fileSize(dir, meta.file) + const [beforeBytes, afterBytes] = await sizes(git, dir, anc, meta, signal) + if (signal?.aborted) throw new Error("Diff detail aborted") if (mime) { const image = await loadImage( meta.file, - meta.status === "added" ? undefined : { bytes: beforeBytes, read: () => readBlob(git, dir, anc, meta.file) }, + meta.status === "added" + ? undefined + : { bytes: beforeBytes, read: () => readBlob(git, dir, anc, meta.file, signal) }, meta.status === "deleted" ? undefined : { bytes: afterBytes, read: () => readFile(dir, meta.file) }, ) + if (signal?.aborted) throw new Error("Diff detail aborted") return { ...summarize(meta), summarized: false, image } } // Cheap size probe before materializing content — protects the extension @@ -444,9 +550,9 @@ async function materialize(git: GitOps, dir: string, anc: string, meta: Meta, lo return summarize(meta) } - const before = await readBefore(git, dir, anc, meta.file, meta.status) - const after = await readAfter(dir, meta.file, meta.status) - const patch = meta.tracked ? await unifiedPatch(git, dir, anc, meta.file) : buildUntrackedPatch(meta.file, after) + const [before, after, tracked] = await detailReads(git, dir, anc, meta, signal) + if (signal?.aborted) throw new Error("Diff detail aborted") + const patch = meta.tracked ? tracked : buildUntrackedPatch(meta.file, after) const additions = meta.status === "added" && meta.additions === 0 && !meta.tracked ? linesOf(after) : meta.additions return { file: meta.file, diff --git a/packages/kilo-vscode/src/agent-manager/project/messages.ts b/packages/kilo-vscode/src/agent-manager/project/messages.ts index 55deaf69801..d3fa3bb80b9 100644 --- a/packages/kilo-vscode/src/agent-manager/project/messages.ts +++ b/packages/kilo-vscode/src/agent-manager/project/messages.ts @@ -45,6 +45,8 @@ export interface ProjectMessageDeps { expand: (ctx: ProjectContext) => void /** Push the current project snapshots to the webview. */ push: () => void + /** Push one project's managed state to the webview. */ + pushState?: (ctx: ProjectContext) => void /** Acknowledge an atomically validated sidebar selection. */ selected: (target: SidebarTarget) => void /** Show a user-facing error. */ @@ -156,6 +158,7 @@ async function openSessionLocally(projectId: string, sessionId: string, deps: Pr } state?.moveSession(sessionId, null) deps.routeSession?.(projectId, sessionId, ctx.root, ctx.generation) + deps.pushState?.(ctx) deps.push() finish({ projectId, kind: "session", sessionId }, deps) } diff --git a/packages/kilo-vscode/src/agent-manager/project/wiring.ts b/packages/kilo-vscode/src/agent-manager/project/wiring.ts index c4a0044a3db..59a47ed0115 100644 --- a/packages/kilo-vscode/src/agent-manager/project/wiring.ts +++ b/packages/kilo-vscode/src/agent-manager/project/wiring.ts @@ -67,6 +67,7 @@ export function createProjectWiring(opts: { expand: opts.expand, ready: opts.ready, push: opts.push, + pushState: opts.pushState, selected: opts.selected, routeSession: opts.routeSession, error: (message) => opts.host.showError(message), diff --git a/packages/kilo-vscode/src/agent-manager/semaphore.ts b/packages/kilo-vscode/src/agent-manager/semaphore.ts index 37f3ab9cdd2..9006c7bf673 100644 --- a/packages/kilo-vscode/src/agent-manager/semaphore.ts +++ b/packages/kilo-vscode/src/agent-manager/semaphore.ts @@ -7,12 +7,12 @@ */ export class Semaphore { private running = 0 - private readonly pending: (() => void)[] = [] + private readonly pending: { resolve: () => void; abort?: () => void }[] = [] constructor(private readonly limit: number) {} - async run(fn: () => Promise): Promise { - await this.acquire() + async run(fn: () => Promise, signal?: AbortSignal): Promise { + await this.acquire(signal) try { return await fn() } finally { @@ -20,22 +20,32 @@ export class Semaphore { } } - private acquire(): Promise { + private acquire(signal?: AbortSignal): Promise { + if (signal?.aborted) return Promise.reject(signal.reason) if (this.running < this.limit) { this.running++ return Promise.resolve() } - return new Promise((resolve) => { - this.pending.push(() => { - this.running++ - resolve() - }) + return new Promise((resolve, reject) => { + const item: { resolve: () => void; abort: () => void } = { + resolve: () => { + signal?.removeEventListener("abort", item.abort) + this.running++ + resolve() + }, + abort: () => { + const index = this.pending.indexOf(item) + if (index !== -1) this.pending.splice(index, 1) + reject(signal?.reason) + }, + } + signal?.addEventListener("abort", item.abort, { once: true }) + this.pending.push(item) }) } private release(): void { this.running-- - const next = this.pending.shift() - if (next) next() + this.pending.shift()?.resolve() } } diff --git a/packages/kilo-vscode/src/diff/SourceController.ts b/packages/kilo-vscode/src/diff/SourceController.ts index 11a51ed3449..8b7aa57d21b 100644 --- a/packages/kilo-vscode/src/diff/SourceController.ts +++ b/packages/kilo-vscode/src/diff/SourceController.ts @@ -67,6 +67,7 @@ export class SourceController { private interval: ReturnType | undefined private lastHash: string | undefined private epoch = 0 + private readonly fetches = new Map>() constructor( private readonly build: (id: string, ctx: PanelContext) => DiffSource, @@ -91,6 +92,7 @@ export class SourceController { stop(): void { this.epoch++ this.stopPolling() + this.fetches.clear() this.active?.dispose?.() this.active = undefined this.activeId = undefined @@ -117,7 +119,7 @@ export class SourceController { if (opts.fetch === false) return - const keepPolling = await this.runFetch(source, epoch, true) + const keepPolling = await this.fetch(source, epoch, true) // Prevents the polling interval from starting after teardown or swap. if (this.epoch !== epoch || this.activeId !== id) return if (opts.poll !== false && keepPolling) this.startPolling(source, epoch) @@ -151,7 +153,7 @@ export class SourceController { // Push fresh diffs immediately after a successful revert so the webview // doesn't have to wait for the next polling tick. if (result.ok && this.epoch === epoch && this.active === source) { - await this.runFetch(source, epoch, false) + await this.fetch(source, epoch, true, true) } } @@ -160,7 +162,7 @@ export class SourceController { const source = this.active if (!source) return const epoch = this.epoch - await this.runFetch(source, epoch, true) + await this.fetch(source, epoch, true, true) } /** @@ -180,6 +182,13 @@ export class SourceController { this.send(this.messages.diffFile(source, file, null)) return } + // Yield once so a worktree switch can advance the epoch before queued + // detail work enters the shared Git semaphore. + await new Promise((resolve) => setTimeout(resolve, 0)) + if (this.epoch !== epoch || this.active !== source) { + this.send(this.messages.diffFile(source, file, null)) + return + } const diff = await source.fetchFile(file).catch(() => null) // Discard stale content after disposal/swap, but still complete the request // so consumers can clear per-file loading state. @@ -239,10 +248,15 @@ export class SourceController { private startPolling(source: DiffSource, epoch: number): void { this.stopPolling() + let busy = false this.interval = setInterval(async () => { + if (busy) return + busy = true // Self-cancel when the tick reports the source is done - const keep = await this.runFetch(source, epoch, false) - if (!keep) this.stopPolling() + const keep = await this.fetch(source, epoch, false).finally(() => { + busy = false + }) + if (!keep && this.epoch === epoch && this.active === source) this.stopPolling() }, DIFF_POLL_INTERVAL_MS) } @@ -252,4 +266,22 @@ export class SourceController { this.interval = undefined } } + + private fetch(source: DiffSource, epoch: number, initial: boolean, force = false): Promise { + const current = this.fetches.get(source) + if (current && !force) return current + if (current) { + return current.then(() => { + if (this.epoch !== epoch || this.active !== source) return false + return this.fetch(source, epoch, initial) + }) + } + const work = this.runFetch(source, epoch, initial) + this.fetches.set(source, work) + const clear = () => { + if (this.fetches.get(source) === work) this.fetches.delete(source) + } + void work.finally(clear).catch(() => undefined) + return work + } } diff --git a/packages/kilo-vscode/src/diff/sources/catalog.ts b/packages/kilo-vscode/src/diff/sources/catalog.ts index 8eb2d0ffdfb..1cfb414a8cf 100644 --- a/packages/kilo-vscode/src/diff/sources/catalog.ts +++ b/packages/kilo-vscode/src/diff/sources/catalog.ts @@ -18,6 +18,12 @@ import { import { TURN_PREFIX, createTurnDiffSource, type TurnDiffFetch } from "./turn" import { STAGED_DESCRIPTOR, STAGED_SOURCE_ID, createStagedDiffSource } from "./staged" import { UNSTAGED_DESCRIPTOR, UNSTAGED_SOURCE_ID, createUnstagedDiffSource } from "./unstaged" +import type { WorktreeDiffEntry } from "../../agent-manager/types" + +export interface LocalDiffSource { + summary: (dir: string, base: string) => Promise + file: (dir: string, base: string, file: string, signal?: AbortSignal) => Promise +} export interface WorkspaceBranchesResult { branches: BranchListItem[] @@ -68,7 +74,10 @@ export class DiffSourceCatalog implements vscode.Disposable { private branchGit: GitOps | undefined private branchOutput: vscode.OutputChannel | undefined - constructor(private readonly connection: KiloConnectionService) {} + constructor( + private readonly connection: KiloConnectionService, + private readonly local?: LocalDiffSource, + ) {} listAvailable(ctx: PanelContext): DiffSourceDescriptor[] { if (ctx.hidePicker) return [] @@ -96,6 +105,8 @@ export class DiffSourceCatalog implements vscode.Disposable { ...opts, baseBranchOverride: ctx.baseBranchOverride, baseBranch: ctx.baseBranch, + summary: this.local?.summary, + file: this.local?.file, }) } diff --git a/packages/kilo-vscode/src/diff/sources/worktree.ts b/packages/kilo-vscode/src/diff/sources/worktree.ts index 89e750a1c87..4aa8b690f1e 100644 --- a/packages/kilo-vscode/src/diff/sources/worktree.ts +++ b/packages/kilo-vscode/src/diff/sources/worktree.ts @@ -45,6 +45,8 @@ export interface WorktreeDiffSourceOptions { /** Shared GitOps / log so sources don't each spawn their own channel. */ git?: GitOps log?: (...args: unknown[]) => void + summary?: (dir: string, base: string) => Promise + file?: (dir: string, base: string, file: string, signal?: AbortSignal) => Promise } /** @@ -57,6 +59,7 @@ export function createWorktreeDiffSource(opts: WorktreeDiffSourceOptions = {}): const output = opts.git ? undefined : vscode.window.createOutputChannel("Kilo Diff: Workspace") const log = opts.log ?? ((...args: unknown[]) => appendOutput(output!, "WorktreeDiffSource", ...args)) const git = opts.git ?? new GitOps({ log }) + const controller = new AbortController() const root = (): string | undefined => { const dir = opts.dir?.() @@ -101,7 +104,9 @@ export function createWorktreeDiffSource(opts: WorktreeDiffSourceOptions = {}): } const status: StatusResolver = async (current, file) => { - const entry = await diffFile(git, current.directory, current.baseBranch, file, log) + const entry = opts.file + ? await opts.file(current.directory, current.baseBranch, file) + : await diffFile(git, current.directory, current.baseBranch, file, log) return entry?.status } @@ -112,7 +117,9 @@ export function createWorktreeDiffSource(opts: WorktreeDiffSourceOptions = {}): const current = await resolveTarget() if (!current) return { diffs: [] } - const entries = await diffSummary(git, current.directory, current.baseBranch, log) + const entries = opts.summary + ? await opts.summary(current.directory, current.baseBranch) + : await diffSummary(git, current.directory, current.baseBranch, log) const diffs = entries.map(toDiffFile) log(`Diff: ${diffs.length} file(s)`) return { diffs } @@ -124,7 +131,9 @@ export function createWorktreeDiffSource(opts: WorktreeDiffSourceOptions = {}): if (!current) return null try { - const entry = await diffFile(git, current.directory, current.baseBranch, file, log) + const entry = opts.file + ? await opts.file(current.directory, current.baseBranch, file, controller.signal) + : await diffFile(git, current.directory, current.baseBranch, file, log) if (!entry) return null return toDiffFile(entry) } catch (err) { @@ -152,6 +161,7 @@ export function createWorktreeDiffSource(opts: WorktreeDiffSourceOptions = {}): // owned by the caller. if (!opts.git) git.dispose() output?.dispose() + controller.abort() target = undefined }, } diff --git a/packages/kilo-vscode/src/features.ts b/packages/kilo-vscode/src/features.ts index 0b0b7275282..c2d3a1f38f6 100644 --- a/packages/kilo-vscode/src/features.ts +++ b/packages/kilo-vscode/src/features.ts @@ -1,4 +1,5 @@ import { hasIndexingPlugin } from "@kilocode/kilo-indexing/detect" +import type { KiloClient } from "@kilocode/sdk/v2" type PluginSpec = string | [string, Record] @@ -9,11 +10,24 @@ type ConfigLike = { export type Features = { indexing: boolean sandboxControls: boolean + backgroundSubagents: boolean } -export function configFeatures(config?: ConfigLike | null): Features { +export function configFeatures(config?: ConfigLike | null, backgroundSubagents = false): Features { return { indexing: hasIndexingPlugin(config?.plugin ?? []), sandboxControls: process.platform !== "win32", + backgroundSubagents, + } +} + +export async function serverFeatures(client: Pick, dir: string) { + if (!client.experimental?.capabilities?.get) return false + try { + const { data } = await client.experimental.capabilities.get({ directory: dir }, { throwOnError: true }) + return data?.backgroundSubagents === true + } catch (error) { + console.warn("[Kilo New] Failed to fetch server capabilities:", error) + return false } } diff --git a/packages/kilo-vscode/src/kilo-provider/abort.ts b/packages/kilo-vscode/src/kilo-provider/abort.ts index 7bb355b5b26..15f7c7f48a1 100644 --- a/packages/kilo-vscode/src/kilo-provider/abort.ts +++ b/packages/kilo-vscode/src/kilo-provider/abort.ts @@ -27,20 +27,27 @@ export class SessionAbort { this.observe(sessionID, status, dir) } - async stop(client: KiloClient, sessionID: string, fallback: string) { - const known = this.active.has(sessionID) + directories(sessionID: string, fallback: string) { const dirs = [...(this.active.get(sessionID) ?? [])] if (!dirs.some((dir) => sameDirectory(dir, fallback))) dirs.push(fallback) + return dirs + } + + async stop(client: KiloClient, sessionID: string, fallback: string, dirs = this.directories(sessionID, fallback)) { + const known = this.active.has(sessionID) const results = await Promise.allSettled(dirs.map((dir) => abortSession({ client, sessionID, dir }))) const failures = results.flatMap((result, index) => result.status === "rejected" ? [{ dir: dirs[index], error: result.reason }] : [], ) if (failures.length > 0) { console.error("[Kilo New] KiloProvider: Failed to abort session in one or more directories:", failures) - return false + return { + complete: false, + attempts: results.map((result, index) => ({ dir: dirs[index], aborted: result.status === "fulfilled" })), + } } if (known) this.active.delete(sessionID) - return known + return { complete: known, attempts: dirs.map((dir) => ({ dir, aborted: true })) } } dispose(dir: string) { diff --git a/packages/kilo-vscode/src/kilo-provider/config-snapshot.ts b/packages/kilo-vscode/src/kilo-provider/config-snapshot.ts index 90ca1ac6fca..088c8b3c20d 100644 --- a/packages/kilo-vscode/src/kilo-provider/config-snapshot.ts +++ b/packages/kilo-vscode/src/kilo-provider/config-snapshot.ts @@ -1,15 +1,16 @@ import type { KiloClient } from "@kilocode/sdk/v2/client" -import { configFeatures } from "../features" +import { configFeatures, serverFeatures } from "../features" import { retry } from "../services/cli-backend/retry" import type { ConfigTarget } from "./config-bindings" -type Client = Pick +type Client = Pick type Settings = { maxCost: number; languageCommitMessage: string; multiProject: boolean } export async function fetchSnapshot(client: Client, dir: string, settings: () => Settings) { - const [{ data: config }, { data: global }, { data: overlay }] = await Promise.all([ + const [{ data: config }, { data: global }, { data: overlay }, capabilities] = await Promise.all([ retry(() => client.config.get({ directory: dir }, { throwOnError: true })), client.global.config.get({ throwOnError: true }), client.config.overlay({ directory: dir, scope: "project" }, { throwOnError: true }), + retry(() => serverFeatures(client, dir)), ]) return { config, @@ -17,6 +18,6 @@ export async function fetchSnapshot(client: Client, dir: string, settings: () => targets: overlay?.targets as { global: ConfigTarget; project: ConfigTarget } | undefined, collections: overlay?.collections, settings: settings(), - features: configFeatures(config), + features: configFeatures(config, capabilities), } } diff --git a/packages/kilo-vscode/src/provider-actions.ts b/packages/kilo-vscode/src/provider-actions.ts index 742380b5604..9d67b2797f9 100644 --- a/packages/kilo-vscode/src/provider-actions.ts +++ b/packages/kilo-vscode/src/provider-actions.ts @@ -10,7 +10,7 @@ import { withCustomProviderDeletions, } from "./shared/custom-provider" import { isCustomProviderPackage, KILO_AUTO, KILO_PROVIDER_ID, parseModelString } from "./shared/provider-model" -import { configFeatures } from "./features" +import { configFeatures, serverFeatures } from "./features" /** * Compute the default model selection from CLI config, VS Code settings, or hardcoded fallback. @@ -240,7 +240,7 @@ async function refreshConfig(ctx: ActionContext, setCachedConfig: SetCachedConfi ctx.client.global.config.get({ throwOnError: true }), ]) if (!config) return - const features = configFeatures(config) + const features = configFeatures(config, await serverFeatures(ctx.client, ctx.workspaceDir)) setCachedConfig({ type: "configLoaded", config, globalConfig: global, features }) ctx.postMessage({ type: "configUpdated", config, globalConfig: global, features }) } @@ -464,9 +464,10 @@ export async function saveCustomProvider( const merged = await ctx.client.config.get({ directory: ctx.workspaceDir }, { throwOnError: true }) const config = merged.data ?? updated - const msg = { type: "configLoaded", config, globalConfig: updated, features: configFeatures(config) } + const features = configFeatures(config, await serverFeatures(ctx.client, ctx.workspaceDir)) + const msg = { type: "configLoaded", config, globalConfig: updated, features } setCachedConfig(msg) - ctx.postMessage({ type: "configUpdated", config, globalConfig: updated, features: configFeatures(config) }) + ctx.postMessage({ type: "configUpdated", config, globalConfig: updated, features }) const auth = resolveCustomProviderAuth(apiKey, apiKeyChanged) diff --git a/packages/kilo-vscode/src/services/cli-backend/connection-service.test.ts b/packages/kilo-vscode/src/services/cli-backend/connection-service.test.ts index 262b2d64bf8..9ec1e5ccf4f 100644 --- a/packages/kilo-vscode/src/services/cli-backend/connection-service.test.ts +++ b/packages/kilo-vscode/src/services/cli-backend/connection-service.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test" import * as vscode from "vscode" import { KiloConnectionService } from "./connection-service" +import type { SSEPayload } from "./sdk-sse-adapter" function state(value: boolean) { return { @@ -39,6 +40,74 @@ describe("KiloConnectionService clients", () => { }) }) +describe("KiloConnectionService explicit aborts", () => { + const close = { + id: "event-close", + type: "session.turn.close", + properties: { sessionID: "session", reason: "interrupted" }, + } as SSEPayload + const status = { + type: "session.status", + properties: { sessionID: "session", status: { type: "busy" } }, + } as SSEPayload + + test("suppresses a successful explicit abort for every subscriber", () => { + const service = new KiloConnectionService({} as any) + const raw: SSEPayload[] = [] + const first: SSEPayload[] = [] + const second: SSEPayload[] = [] + service.onEvent((event) => raw.push(event)) + service.onEventFiltered( + () => true, + (event) => first.push(event), + ) + service.onEventFiltered( + () => true, + (event) => second.push(event), + ) + ;(service as any).broadcast(status, "/repo") + raw.length = 0 + first.length = 0 + second.length = 0 + + const id = service.beginExplicitAbort("session", "/repo") + ;(service as any).broadcast(close, "/repo") + service.finishExplicitAbort("session", "/repo", id, true) + + expect(first).toEqual([]) + expect(second).toEqual([]) + expect(raw).toEqual([close]) + }) + + test("replays a failed explicit abort for every subscriber", () => { + const service = new KiloConnectionService({} as any) + const raw: SSEPayload[] = [] + const first: SSEPayload[] = [] + const second: SSEPayload[] = [] + service.onEvent((event) => raw.push(event)) + service.onEventFiltered( + () => true, + (event) => first.push(event), + ) + service.onEventFiltered( + () => true, + (event) => second.push(event), + ) + ;(service as any).broadcast(status, "/repo") + raw.length = 0 + first.length = 0 + second.length = 0 + + const id = service.beginExplicitAbort("session", "/repo") + ;(service as any).broadcast(close, "/repo") + service.finishExplicitAbort("session", "/repo", id, false) + + expect(first).toEqual([close]) + expect(second).toEqual([close]) + expect(raw).toEqual([close]) + }) +}) + describe("KiloConnectionService viewed sessions", () => { test("keeps Agent Manager sessions when sidebar visibility changes during a flush", async () => { const service = new KiloConnectionService({} as any) diff --git a/packages/kilo-vscode/src/services/cli-backend/connection-service.ts b/packages/kilo-vscode/src/services/cli-backend/connection-service.ts index 1b0e8679d38..73585d15342 100644 --- a/packages/kilo-vscode/src/services/cli-backend/connection-service.ts +++ b/packages/kilo-vscode/src/services/cli-backend/connection-service.ts @@ -5,6 +5,7 @@ import { SdkSSEAdapter, type SSEPayload } from "./sdk-sse-adapter" import type { ServerConfig } from "./types" import { createDuplicateEventFilter, resolveEventSessionId as resolveEventSessionIdPure } from "./connection-utils" import { SandboxPreference } from "../sandbox-preference" +import { ExplicitAbortState } from "./explicit-abort" export type ConnectionState = "connecting" | "connected" | "disconnected" | "error" type SSEEventListener = (event: SSEPayload, directory?: string) => void @@ -96,7 +97,8 @@ export class KiloConnectionService { private remoteService: import("../RemoteStatusService").RemoteStatusService | null = null private readonly eventListeners: Set = new Set() - private readonly duplicateEvent = createDuplicateEventFilter() + private readonly filteredListeners = new Set<{ filter: SSEEventFilter; listener: SSEEventListener }>() + private readonly explicitAborts = new ExplicitAbortState() private readonly stateListeners: Set = new Set() private readonly notificationDismissListeners: Set = new Set() private readonly languageChangeListeners: Set = new Set() @@ -277,13 +279,34 @@ export class KiloConnectionService { * Subscribe to SSE events with a filter. The filter runs for every incoming SSE event. */ onEventFiltered(filter: SSEEventFilter, listener: SSEEventListener): () => void { - const wrapped: SSEEventListener = (event, directory) => { - if (!filter(event, directory)) { - return - } - listener(event, directory) + const entry = { filter, listener } + this.filteredListeners.add(entry) + return () => { + this.filteredListeners.delete(entry) } - return this.onEvent(wrapped) + } + + beginExplicitAbort(sessionID: string, directory: string): number | undefined { + return this.explicitAborts.begin(sessionID, directory) + } + + finishExplicitAbort(sessionID: string, directory: string, id: number | undefined, stopped: boolean): void { + for (const item of this.explicitAborts.finish(sessionID, directory, id, stopped)) + this.broadcastFiltered(item.event, item.directory) + } + + async runExplicitAbort(sessionID: string, directory: string, action: () => Promise): Promise { + const id = this.beginExplicitAbort(sessionID, directory) + return action().then( + (result) => { + this.finishExplicitAbort(sessionID, directory, id, true) + return result + }, + (error) => { + this.finishExplicitAbort(sessionID, directory, id, false) + throw error + }, + ) } /** @@ -306,6 +329,7 @@ export class KiloConnectionService { * id after external (CLI/TUI/cascade) deletes arrive via SSE. */ pruneSession(sessionId: string): void { + this.explicitAborts.remove(sessionId) for (const [mid, sid] of this.messageSessionIdsByMessageId) { if (sid === sessionId) this.messageSessionIdsByMessageId.delete(mid) } @@ -682,6 +706,8 @@ export class KiloConnectionService { this.sseClient?.dispose() this.serverManager.dispose() this.eventListeners.clear() + this.filteredListeners.clear() + this.explicitAborts.clear() this.stateListeners.clear() this.notificationDismissListeners.clear() this.profileChangeListeners.clear() @@ -781,6 +807,7 @@ export class KiloConnectionService { this.stopHealthPoll() this.stopCheckin() const sse = this.sseClient + this.explicitAborts.clear() this.sseClient = null sse?.disconnect() this.client = null @@ -821,6 +848,7 @@ export class KiloConnectionService { }, }) const sse = new SdkSSEAdapter(client) + const duplicateEvent = createDuplicateEventFilter() this.client = client this.sseClient = sse @@ -839,12 +867,8 @@ export class KiloConnectionService { sse.onEvent((event, directory) => { if (this.sseClient !== sse) return // EventV2Bridge also emits these durable compatibility envelopes after their normal live events. - if (this.duplicateEvent(event)) return - this.handlePermissionEvent(event, directory) - this.handleQuestionEvent(event, directory) - for (const listener of this.eventListeners) { - listener(event, directory) - } + if (duplicateEvent(event)) return + this.broadcast(event, directory) }) sse.onError((error) => { @@ -890,6 +914,20 @@ export class KiloConnectionService { this.startHealthPoll(config.baseUrl, config.password) } + private broadcast(event: SSEPayload, directory?: string): void { + this.handlePermissionEvent(event, directory) + this.handleQuestionEvent(event, directory) + for (const listener of this.eventListeners) listener(event, directory) + if (!this.explicitAborts.event(event, directory)) return + this.broadcastFiltered(event, directory) + } + + private broadcastFiltered(event: SSEPayload, directory?: string): void { + for (const entry of this.filteredListeners) { + if (entry.filter(event, directory)) entry.listener(event, directory) + } + } + private startCheckin(): void { this.stopCheckin() this.checkinTimer = setInterval(() => this.flushViewed(), 60_000) diff --git a/packages/kilo-vscode/src/services/cli-backend/connection-utils.ts b/packages/kilo-vscode/src/services/cli-backend/connection-utils.ts index 271eec41148..8a25d852744 100644 --- a/packages/kilo-vscode/src/services/cli-backend/connection-utils.ts +++ b/packages/kilo-vscode/src/services/cli-backend/connection-utils.ts @@ -24,8 +24,8 @@ export function createDuplicateEventFilter() { } if (duplicateLiveEvents.has(event.type)) { + if (seen.size >= DUPLICATE_EVENT_LIMIT) seen.delete(seen.values().next().value!) seen.add(event.id) - if (seen.size > DUPLICATE_EVENT_LIMIT) seen.delete(seen.values().next().value!) } return false } diff --git a/packages/kilo-vscode/src/services/cli-backend/explicit-abort.ts b/packages/kilo-vscode/src/services/cli-backend/explicit-abort.ts new file mode 100644 index 00000000000..b86e9121a2b --- /dev/null +++ b/packages/kilo-vscode/src/services/cli-backend/explicit-abort.ts @@ -0,0 +1,112 @@ +import path from "node:path" +import type { SSEPayload } from "./sdk-sse-adapter" + +type Buffered = { event: SSEPayload; directory?: string } +type State = { attempts: Set; stopped: boolean; buffered: Buffered[]; generation: number; idle: boolean } + +export class ExplicitAbortState { + private readonly active = new Set() + private readonly states = new Map() + private readonly generations = new Map() + private next = 0 + + begin(sessionID: string, directory: string): number | undefined { + const key = scope(sessionID, directory) + if (!this.active.has(key)) return + const id = ++this.next + const state = this.states.get(key) ?? { + attempts: new Set(), + stopped: false, + buffered: [], + generation: this.generations.get(key) ?? 0, + idle: false, + } + state.attempts.add(id) + this.states.set(key, state) + return id + } + + finish(sessionID: string, directory: string, id: number | undefined, stopped: boolean): Buffered[] { + if (id === undefined) return [] + const key = scope(sessionID, directory) + const state = this.states.get(key) + if (!state || !state.attempts.delete(id)) return [] + if (stopped) { + state.stopped = true + state.buffered = [] + return [] + } + if (state.stopped || state.attempts.size > 0) return [] + this.states.delete(key) + return state.buffered + } + + event(event: SSEPayload, directory?: string): boolean { + if (event.type === "session.status" && directory) return this.status(event, directory) + if (event.type === "session.turn.open") return this.open(event.properties.sessionID, directory) + if (event.type !== "session.turn.close") return true + const keys = this.keys(event.properties.sessionID, directory).filter((key) => this.states.has(key)) + if (keys.length !== 1) return true + const key = keys[0] + const state = this.states.get(key) + if (!state) return true + if (state.generation !== (this.generations.get(key) ?? 0) || event.properties.reason !== "interrupted") { + this.states.delete(key) + return true + } + if (state.stopped) return false + if (state.attempts.size === 0) { + this.states.delete(key) + return true + } + state.buffered.push({ event, directory }) + return false + } + + private status(event: Extract, directory: string) { + const key = scope(event.properties.sessionID, directory) + const state = this.states.get(key) + if (event.properties.status.type === "idle") { + this.active.delete(key) + if (state) state.idle = true + return true + } + this.active.add(key) + if (state?.idle) this.states.delete(key) + return true + } + + private open(sessionID: string, directory?: string) { + for (const key of this.keys(sessionID, directory)) { + this.generations.set(key, (this.generations.get(key) ?? 0) + 1) + this.states.delete(key) + } + return true + } + + clear() { + this.active.clear() + this.states.clear() + this.generations.clear() + } + + remove(sessionID: string) { + for (const key of this.keys(sessionID)) { + this.active.delete(key) + this.states.delete(key) + this.generations.delete(key) + } + } + + private keys(sessionID: string, directory?: string): string[] { + if (directory) return [scope(sessionID, directory)] + const prefix = `${sessionID}\0` + return [...new Set([...this.active, ...this.states.keys(), ...this.generations.keys()])].filter((key) => + key.startsWith(prefix), + ) + } +} + +function scope(sessionID: string, directory: string) { + return `${sessionID}\0${path.resolve(directory)}` +} diff --git a/packages/kilo-vscode/tests/unit/abort.test.ts b/packages/kilo-vscode/tests/unit/abort.test.ts index e971a1211b8..af80429270a 100644 --- a/packages/kilo-vscode/tests/unit/abort.test.ts +++ b/packages/kilo-vscode/tests/unit/abort.test.ts @@ -20,7 +20,13 @@ describe("SessionAbort", () => { const aborts = new SessionAbort() aborts.observe("session_1", "busy", "/repo") - expect(await aborts.stop(client(calls), "session_1", "/repo/worktree")).toBe(true) + expect(await aborts.stop(client(calls), "session_1", "/repo/worktree")).toEqual({ + complete: true, + attempts: [ + { dir: "/repo", aborted: true }, + { dir: "/repo/worktree", aborted: true }, + ], + }) expect(calls).toEqual([ { type: "abort", @@ -41,7 +47,10 @@ describe("SessionAbort", () => { aborts.observe("session_1", "busy", "/repo") aborts.observe("session_1", "idle", "/repo") - expect(await aborts.stop(client(calls), "session_1", "/repo/worktree")).toBe(false) + expect(await aborts.stop(client(calls), "session_1", "/repo/worktree")).toEqual({ + complete: false, + attempts: [{ dir: "/repo/worktree", aborted: true }], + }) expect(calls).toEqual([ { type: "abort", @@ -56,9 +65,23 @@ describe("SessionAbort", () => { const aborts = new SessionAbort() aborts.observe("session_1", "busy", "/repo/worktree") - expect(await aborts.stop(client(calls), "session_1", "/repo/worktree/.")).toBe(true) + expect(await aborts.stop(client(calls), "session_1", "/repo/worktree/.")).toEqual({ + complete: true, + attempts: [{ dir: "/repo/worktree", aborted: true }], + }) expect(calls).toHaveLength(1) }) + + it("reports a failed HTTP abort separately from ownership", async () => { + const calls: unknown[] = [] + const aborts = new SessionAbort() + aborts.observe("session_1", "busy", "/repo") + + expect(await aborts.stop(client(calls, true), "session_1", "/repo")).toEqual({ + complete: false, + attempts: [{ dir: "/repo", aborted: false }], + }) + }) }) describe("abortSession", () => { diff --git a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts index c90ec955ad1..249efbaee68 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts @@ -29,6 +29,8 @@ const TSX_FILES = [ path.join(ROOT, "webview-ui/agent-manager/ProjectSelect.tsx"), path.join(ROOT, "webview-ui/agent-manager/sortable-tab.tsx"), path.join(ROOT, "webview-ui/agent-manager/DiffPanel.tsx"), + path.join(ROOT, "webview-ui/agent-manager/DiffPanelCache.tsx"), + path.join(ROOT, "webview-ui/agent-manager/review-composers.ts"), path.join(ROOT, "webview-ui/documents/DocumentPanel.tsx"), path.join(ROOT, "webview-ui/diff-viewer/FullScreenDiffView.tsx"), path.join(ROOT, "webview-ui/diff-viewer/ImageDiffView.tsx"), diff --git a/packages/kilo-vscode/tests/unit/agent-manager-sidebar-scroll.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-sidebar-scroll.test.ts new file mode 100644 index 00000000000..43c63160fc0 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-manager-sidebar-scroll.test.ts @@ -0,0 +1,165 @@ +import { afterEach, describe, expect, it } from "bun:test" +import { Window } from "happy-dom" +import { createSidebarScrollPreserver } from "../../webview-ui/agent-manager/sidebar-scroll" + +const window = new Window() +const frames = new Map() +let id = 0 + +function schedule(fn: FrameRequestCallback) { + const next = ++id + frames.set(next, fn) + return next +} + +function cancel(id: number) { + frames.delete(id) +} + +function preserver(active: () => string | null | undefined = () => undefined) { + return createSidebarScrollPreserver(active, window.document, schedule, cancel) +} + +afterEach(() => { + window.document.body.innerHTML = "" + frames.clear() + id = 0 +}) + +function list(cls = "am-worktree-list") { + const el = window.document.createElement("div") + el.className = cls + Object.defineProperty(el, "scrollTop", { configurable: true, value: 0, writable: true }) + window.document.body.append(el) + return el +} + +function flush() { + for (let i = 0; i < 2; i++) { + const next = frames.entries().next().value + if (!next) return + frames.delete(next[0]) + next[1](0) + } +} + +describe("Agent Manager sidebar scroll preservation", () => { + it("restores the scroll offset after the state update has rendered", () => { + const el = list() + el.scrollTop = 240 + const preserve = preserver() + + preserve(() => { + el.scrollTop = 0 + }) + + expect(el.scrollTop).toBe(0) + flush() + expect(el.scrollTop).toBe(240) + }) + + it("tracks project and worktree scroll owners independently", () => { + const projects = list("am-projects-list") + const worktrees = list() + projects.scrollTop = 120 + worktrees.scrollTop = 80 + const preserve = preserver() + + preserve(() => { + projects.scrollTop = 0 + worktrees.scrollTop = 0 + }) + flush() + + expect(projects.scrollTop).toBe(120) + expect(worktrees.scrollTop).toBe(80) + }) + + it("does not override intentional selection scrolling", () => { + const el = list() + el.scrollTop = 240 + const preserve = preserver() + + preserve(() => { + el.scrollTop = 140 + }) + flush() + + expect(el.scrollTop).toBe(140) + }) + + it("does not restore when the selected worktree changes during the update", () => { + const el = list() + let selected = "first" + el.scrollTop = 240 + const preserve = preserver(() => selected) + + preserve(() => { + el.scrollTop = 0 + selected = "second" + }) + flush() + + expect(el.scrollTop).toBe(0) + }) + + it("does not restore when selection changes before the delayed frame", () => { + const el = list() + let selected = "first" + el.scrollTop = 240 + const preserve = preserver(() => selected) + + preserve(() => { + el.scrollTop = 0 + }) + selected = "second" + flush() + + expect(el.scrollTop).toBe(0) + }) + + it("keeps intentional scrolling from the top of the list", () => { + const el = list() + const preserve = preserver() + + preserve(() => { + el.scrollTop = 180 + }) + flush() + + expect(el.scrollTop).toBe(180) + }) + + it("cancels stale restores when a newer state arrives", () => { + const el = list() + const preserve = preserver() + el.scrollTop = 120 + + preserve(() => { + el.scrollTop = 0 + }) + el.scrollTop = 210 + preserve(() => { + el.scrollTop = 0 + }) + flush() + + expect(el.scrollTop).toBe(210) + expect(frames.size).toBe(0) + }) + + it("does not restore a container that was removed by the update", () => { + const el = list() + el.scrollTop = 160 + const preserve = preserver() + + preserve(() => { + el.remove() + el.scrollTop = 0 + }) + flush() + + expect(el.isConnected).toBe(false) + expect(el.scrollTop).toBe(0) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-layout.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-layout.test.ts index bbf0eee69ed..9f9220edf54 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-terminal-layout.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-layout.test.ts @@ -85,6 +85,11 @@ test("orders local terminal status lines through the output batcher", () => { expect(terminal).not.toContain("term.writeln(") }) +test("uses a browser-valid close code when replay overflows", () => { + expect(terminal).not.toContain("close(1009,") + expect(terminal).toContain('close(4009, "terminal replay exceeded limit")') +}) + test("keeps raw PTY line endings and initializes Unicode widths before attaching", () => { expect(terminal).toContain("convertEol: false") expect(terminal).toContain('term.unicode.activeVersion = "15-graphemes"') diff --git a/packages/kilo-vscode/tests/unit/agent-manager-worktree-diffs.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-worktree-diffs.test.ts index 5d2e5bc3e11..4965ca1bf1c 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-worktree-diffs.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-worktree-diffs.test.ts @@ -34,7 +34,7 @@ describe("createWorktreeDiffs", () => { it("stores full diffs per session", () => { withDiffs((diffs) => { diffs.onWorktreeDiff({ type: "agentManager.worktreeDiff", sessionId: "s1", diffs: [diff("a.ts")] }) - expect(diffs.diffDatas()["s1"]).toHaveLength(1) + expect(diffs.diffDatas()["single\0s1"]).toHaveLength(1) }) }) @@ -56,7 +56,7 @@ describe("createWorktreeDiffs", () => { file: "a.ts", diff: diff("a.ts", 9), }) - expect(diffs.diffDatas()["s1"]![0]!.additions).toBe(9) + expect(diffs.diffDatas()["single\0s1"]![0]!.additions).toBe(9) expect(diffs.diffFileLoadingFor(() => "s1").size).toBe(0) }) }) @@ -65,11 +65,22 @@ describe("createWorktreeDiffs", () => { withDiffs((diffs) => { diffs.onWorktreeDiffLoading({ type: "agentManager.worktreeDiffLoading", sessionId: "s1", loading: true }) expect(diffs.diffLoading()).toBe(true) + expect(diffs.diffLoadingFor(() => "s1")).toBe(true) + diffs.onWorktreeDiff({ type: "agentManager.worktreeDiff", sessionId: "s1", diffs: [] }) + expect(diffs.diffLoadingFor(() => "s1")).toBe(false) diffs.onWorktreeDiffLoading({ type: "agentManager.worktreeDiffLoading", sessionId: "s1", loading: false }) expect(diffs.diffLoading()).toBe(false) }) }) + it("keeps loading isolated to its composite diff id", () => { + withDiffs((diffs) => { + diffs.onWorktreeDiffLoading({ type: "agentManager.worktreeDiffLoading", sessionId: "s1#branch", loading: true }) + expect(diffs.diffLoadingFor(() => "s1#branch")).toBe(true) + expect(diffs.diffLoadingFor(() => "s2#branch")).toBe(false) + }) + }) + it("requestDiffFile marks a file pending, posts once, and ignores repeats", () => { withDiffs((diffs, sent) => { diffs.requestDiffFile("s1", "a.ts") diff --git a/packages/kilo-vscode/tests/unit/agent-project-selection.test.ts b/packages/kilo-vscode/tests/unit/agent-project-selection.test.ts index 7e7e746022f..171df5b67de 100644 --- a/packages/kilo-vscode/tests/unit/agent-project-selection.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-project-selection.test.ts @@ -19,6 +19,7 @@ function fakeState(persisted?: { current?: unknown }) { return { getWorktree: (id: string) => (id === "wt1" ? { path: "/repo/prj-extra/wt1" } : undefined), getSession: (id: string) => (id === "sess1" ? {} : undefined), + moveSession: () => {}, getActiveTarget: () => store.current, setActiveTarget: (target: unknown) => { store.current = target @@ -172,6 +173,27 @@ describe("activateSelection — cross-project selection", () => { expect(calls.error).toEqual([]) }) + it("pushes moved-session state before acknowledging local activation", async () => { + const { contexts, deps, calls, extra } = setup() + const ctx = contexts.expand(extra)! + ctx.stateManager() + await ctx.ensureReady(async () => ({ ok: true, refsFixed: 0 })) + contexts.activate(extra) + + const order: string[] = [] + deps.push = () => order.push("projects") + deps.pushState = () => order.push("state") + deps.selected = () => order.push("selected") + + await handleProjectMessage( + { type: "agentManager.openSessionLocally", projectId: extra, sessionId: "sess1" } as never, + deps, + ) + + expect(order).toEqual(["state", "projects", "projects", "selected"]) + expect(calls.error).toEqual([]) + }) + it("restores the persisted target when the selection asks for it", async () => { const persisted = { current: undefined as unknown } const { contexts, deps, calls, extra } = setup({ state: () => fakeState(persisted) }) diff --git a/packages/kilo-vscode/tests/unit/background-agents.test.ts b/packages/kilo-vscode/tests/unit/background-agents.test.ts index 4689ddb8101..567e663b400 100644 --- a/packages/kilo-vscode/tests/unit/background-agents.test.ts +++ b/packages/kilo-vscode/tests/unit/background-agents.test.ts @@ -5,6 +5,7 @@ import { showBackgroundAgent, } from "../../webview-ui/src/components/chat/background-agents" import { childForeground, showChildPromotion } from "../../webview-ui/src/components/chat/task-tool-state" +import { latestTaskPart } from "../../webview-ui/src/context/session-utils" import type { BackgroundJobInfo, PermissionRequest, @@ -77,17 +78,32 @@ describe("backgroundAgents", () => { it("identifies each parallel foreground child independently", () => { const status = { ses_a: busy, ses_b: busy } - expect(childForeground("ses_a", {}, {}, status)).toBe(true) - expect(childForeground("ses_b", {}, {}, status)).toBe(true) - expect(childForeground("ses_a", { background: true }, {}, status)).toBe(false) - expect(childForeground("ses_b", {}, { background: true }, status)).toBe(false) - expect(childForeground("ses_a", {}, {}, { ses_a: idle })).toBe(false) - expect(childForeground("ses_a", {}, {}, { ses_a: { type: "retry", attempt: 1, message: "retry", next: 1 } })).toBe( - true, - ) - expect(childForeground(undefined, {}, {}, status)).toBe(false) - expect(showChildPromotion("ses_a", {}, {}, status, false)).toBe(true) - expect(showChildPromotion("ses_a", {}, {}, status, true)).toBe(false) + expect(childForeground("ses_a", {}, {}, status, true)).toBe(true) + expect(childForeground("ses_b", {}, {}, status, true)).toBe(true) + expect(childForeground("ses_a", { background: true }, {}, status, true)).toBe(false) + expect(childForeground("ses_b", {}, { background: true }, status, true)).toBe(false) + expect(childForeground("ses_a", {}, {}, { ses_a: idle }, true)).toBe(false) + expect( + childForeground("ses_a", {}, {}, { ses_a: { type: "retry", attempt: 1, message: "retry", next: 1 } }, true), + ).toBe(true) + expect(childForeground(undefined, {}, {}, status, true)).toBe(false) + expect(childForeground("ses_a", {}, {}, status, false)).toBe(false) + expect(showChildPromotion("ses_a", {}, {}, status, true, false, true)).toBe(true) + expect(showChildPromotion("ses_a", {}, {}, status, true, true, true)).toBe(false) + expect(showChildPromotion("ses_a", {}, {}, status, false, false, true)).toBe(false) + expect(showChildPromotion("ses_a", {}, {}, status, undefined, false, true)).toBe(false) + }) + + it("only promotes the latest task part for a resumed child", () => { + const parts = [ + taskPart({ id: "part_old", child: "ses_a" }), + taskPart({ id: "part_new", child: "ses_a" }), + taskPart({ id: "part_other", child: "ses_b" }), + ] + + expect(latestTaskPart("part_old", "ses_a", parts)).toBe(false) + expect(latestTaskPart("part_new", "ses_a", parts)).toBe(true) + expect(latestTaskPart("part_other", "ses_b", parts)).toBe(true) }) it("ignores agents whose session is no longer working", () => { diff --git a/packages/kilo-vscode/tests/unit/connection-utils.test.ts b/packages/kilo-vscode/tests/unit/connection-utils.test.ts index c1865616e7d..3a31216509d 100644 --- a/packages/kilo-vscode/tests/unit/connection-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/connection-utils.test.ts @@ -173,7 +173,7 @@ describe("resolveEventSessionId", () => { }) }) -describe("isDuplicateSyncEvent", () => { +describe("createDuplicateEventFilter", () => { it("drops a compatibility envelope only after its live event", () => { const filter = createDuplicateEventFilter() const live = { @@ -233,4 +233,130 @@ describe("isDuplicateSyncEvent", () => { ), ).toBe(false) }) + + it("continues tracking new live events after the cap is reached", () => { + const filter = createDuplicateEventFilter() + for (let index = 0; index < 1024; index++) { + expect( + filter({ + id: `live-${index}`, + type: "message.part.updated", + properties: { sessionID: "s6", part, delta: "x" }, + }), + ).toBe(false) + } + + expect( + filter({ + id: "live-1024", + type: "message.part.updated", + properties: { sessionID: "s6", part, delta: "x" }, + }), + ).toBe(false) + expect( + filter( + sync({ + type: "sync", + name: "message.part.updated.1", + id: "live-1024", + seq: 9, + aggregateID: "s6", + data: { sessionID: "s6", part, time: 0 }, + }), + ), + ).toBe(true) + expect( + filter( + sync({ + type: "sync", + name: "message.part.updated.1", + id: "live-0", + seq: 8, + aggregateID: "s6", + data: { sessionID: "s6", part, time: 0 }, + }), + ), + ).toBe(false) + expect( + filter( + sync({ + type: "sync", + name: "message.part.updated.1", + id: "live-1024", + seq: 9, + aggregateID: "s6", + data: { sessionID: "s6", part, time: 0 }, + }), + ), + ).toBe(false) + }) + + it("forwards delayed envelopes for evicted IDs", () => { + const filter = createDuplicateEventFilter() + for (let index = 0; index < 1024; index++) { + expect( + filter({ + id: `pending-${index}`, + type: "message.part.updated", + properties: { sessionID: "s6", part, delta: "x" }, + }), + ).toBe(false) + } + + expect( + filter({ + id: "overflow", + type: "message.part.updated", + properties: { sessionID: "s6", part, delta: "x" }, + }), + ).toBe(false) + expect( + filter( + sync({ + type: "sync", + name: "message.part.updated.1", + id: "pending-0", + seq: 8, + aggregateID: "s6", + data: { sessionID: "s6", part, time: 0 }, + }), + ), + ).toBe(false) + expect( + filter( + sync({ + type: "sync", + name: "message.part.updated.1", + id: "pending-1023", + seq: 9, + aggregateID: "s6", + data: { sessionID: "s6", part, time: 0 }, + }), + ), + ).toBe(true) + }) + + it("does not carry duplicate IDs between connections", () => { + const first = createDuplicateEventFilter() + const second = createDuplicateEventFilter() + const live = { + id: "connection-event", + type: "message.part.updated", + properties: { sessionID: "s6", part, delta: "x" }, + } satisfies Payload + + expect(first(live)).toBe(false) + expect( + second( + sync({ + type: "sync", + name: "message.part.updated.1", + id: "connection-event", + seq: 11, + aggregateID: "s6", + data: { sessionID: "s6", part, time: 0 }, + }), + ), + ).toBe(false) + }) }) diff --git a/packages/kilo-vscode/tests/unit/continue-in-worktree.test.ts b/packages/kilo-vscode/tests/unit/continue-in-worktree.test.ts index 86d341b938f..52c4e337487 100644 --- a/packages/kilo-vscode/tests/unit/continue-in-worktree.test.ts +++ b/packages/kilo-vscode/tests/unit/continue-in-worktree.test.ts @@ -72,8 +72,11 @@ function result(path: string): CreateWorktreeResult { function ctx(overrides: Partial = {}): ContinueContext { return { root: "/tmp/test", - getClient: () => { - throw new Error("no client") + connection: { + getClient: () => { + throw new Error("no client") + }, + runExplicitAbort: async (_sessionId, _directory, action) => action(), }, createWorktreeOnDisk: async () => null, runSetupScript: async () => {}, @@ -98,10 +101,12 @@ describe("continue-in-worktree steps", () => { it("does not throw when abort rejects", async () => { const c = ctx({ - getClient: () => - ({ - session: { abort: () => Promise.reject(new Error("fail")) }, - }) as never, + connection: { + getClient: () => ({ session: { abort: async () => undefined } }) as never, + runExplicitAbort: async () => { + throw new Error("fail") + }, + }, }) await abortSession(c, "session-1") }) @@ -109,15 +114,17 @@ describe("continue-in-worktree steps", () => { it("calls abort on the client", async () => { let called = false const c = ctx({ - getClient: () => - ({ - session: { - abort: () => { - called = true - return Promise.resolve() + connection: { + getClient: () => + ({ + session: { + abort: async () => { + called = true + }, }, - }, - }) as never, + }) as never, + runExplicitAbort: async (_sessionId, _directory, action) => action(), + }, }) await abortSession(c, "session-1") expect(called).toBe(true) @@ -134,10 +141,13 @@ describe("continue-in-worktree steps", () => { it("returns error when fork rejects", async () => { const c = ctx({ - getClient: () => - ({ - session: { fork: () => Promise.reject(new Error("fork failed")) }, - }) as never, + connection: { + getClient: () => + ({ + session: { fork: () => Promise.reject(new Error("fork failed")) }, + }) as never, + runExplicitAbort: async (_s, _d, action) => action(), + }, }) const res = await forkSession(c, "session-1", "/tmp/wt") expect(res.ok).toBe(false) @@ -148,10 +158,13 @@ describe("continue-in-worktree steps", () => { const forked = session("forked-1") const promptAsync = mock(async () => ({})) const c = ctx({ - getClient: () => - ({ - session: { fork: () => Promise.resolve({ data: forked }), promptAsync }, - }) as never, + connection: { + getClient: () => + ({ + session: { fork: () => Promise.resolve({ data: forked }), promptAsync }, + }) as never, + runExplicitAbort: async (_s, _d, action) => action(), + }, }) const res = await forkSession(c, "session-1", "/tmp/wt") expect(res.ok).toBe(true) @@ -215,7 +228,7 @@ describe("continueInWorktree", () => { let created: CreateWorktreeResult | undefined const c = ctx({ root, - getClient: () => api, + connection: { getClient: () => api, runExplicitAbort: async (_s, _d, action) => action() }, createWorktreeOnDisk: async (opts) => { const value = await manager.createWorktree(opts) created = value @@ -246,7 +259,7 @@ describe("continueInWorktree", () => { let created: CreateWorktreeResult | undefined const c = ctx({ root, - getClient: () => client(), + connection: { getClient: () => client(), runExplicitAbort: async (_s, _d, action) => action() }, createWorktreeOnDisk: async (opts) => { const value = await manager.createWorktree(opts) created = value diff --git a/packages/kilo-vscode/tests/unit/explicit-abort.test.ts b/packages/kilo-vscode/tests/unit/explicit-abort.test.ts new file mode 100644 index 00000000000..68944787091 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/explicit-abort.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "bun:test" +import type { SSEPayload } from "../../src/services/cli-backend/sdk-sse-adapter" +import { ExplicitAbortState } from "../../src/services/cli-backend/explicit-abort" + +const open = (sessionID = "session") => + ({ id: "event-open", type: "session.turn.open", properties: { sessionID } }) as SSEPayload + +const status = (type: "idle" | "busy", sessionID = "session") => + ({ type: "session.status", properties: { sessionID, status: { type } } }) as SSEPayload + +const close = (reason: "completed" | "interrupted", sessionID = "session") => + ({ id: `event-${reason}`, type: "session.turn.close", properties: { sessionID, reason } }) as SSEPayload + +describe("explicit abort state", () => { + it("does not suppress an unexpected interruption", () => { + const state = new ExplicitAbortState() + + expect(state.event(close("interrupted"))).toBe(true) + }) + + it("drops an interrupted close after an explicit abort succeeds", () => { + const state = new ExplicitAbortState() + state.event(status("busy"), "/repo") + const id = state.begin("session", "/repo") + + expect(state.event(close("interrupted"), "/repo")).toBe(false) + expect(state.finish("session", "/repo", id, true)).toEqual([]) + }) + + it("drops an interrupted close that arrives after abort success", () => { + const state = new ExplicitAbortState() + state.event(status("busy"), "/repo") + const id = state.begin("session", "/repo") + state.finish("session", "/repo", id, true) + + expect(state.event(close("interrupted"), "/repo")).toBe(false) + }) + + it("replays an interrupted close when the abort fails", () => { + const state = new ExplicitAbortState() + state.event(status("busy"), "/repo") + const id = state.begin("session", "/repo") + const event = close("interrupted") + state.event(event, "/repo") + + expect(state.finish("session", "/repo", id, false)).toEqual([{ event, directory: "/repo" }]) + }) + + it("never suppresses a completed close", () => { + const state = new ExplicitAbortState() + state.event(status("busy"), "/repo") + state.begin("session", "/repo") + + expect(state.event(close("completed"))).toBe(true) + }) + + it("waits for concurrent abort attempts before replaying", () => { + const state = new ExplicitAbortState() + state.event(status("busy"), "/repo") + const first = state.begin("session", "/repo") + const second = state.begin("session", "/repo") + state.event(close("interrupted"), "/repo") + + expect(state.finish("session", "/repo", first, false)).toEqual([]) + expect(state.finish("session", "/repo", second, true)).toEqual([]) + }) + + it("allows a later real interruption in the same session", () => { + const state = new ExplicitAbortState() + state.event(open(), "/repo") + state.event(status("busy"), "/repo") + const id = state.begin("session", "/repo") + state.finish("session", "/repo", id, true) + expect(state.event(close("interrupted"), "/repo")).toBe(false) + state.event(status("idle"), "/repo") + state.event(open(), "/repo") + state.event(status("busy"), "/repo") + + expect(state.event(close("interrupted"), "/repo")).toBe(true) + }) + + it("clears a pending abort when a new turn opens", () => { + const state = new ExplicitAbortState() + state.event(status("busy"), "/repo") + const id = state.begin("session", "/repo") + state.event(open(), "/repo") + + expect(state.event(close("interrupted"), "/repo")).toBe(true) + expect(state.finish("session", "/repo", id, true)).toEqual([]) + }) + + it("isolates identical session ids by directory", () => { + const state = new ExplicitAbortState() + state.event(status("busy"), "/repo/a") + const id = state.begin("session", "/repo/a") + state.finish("session", "/repo/a", id, true) + + expect(state.event(close("interrupted"), "/repo/b")).toBe(true) + expect(state.event(close("interrupted"), "/repo/a")).toBe(false) + }) + + it("does not mark an already idle session", () => { + const state = new ExplicitAbortState() + state.event(status("idle"), "/repo") + + expect(state.begin("session", "/repo")).toBeUndefined() + expect(state.event(close("interrupted"), "/repo")).toBe(true) + }) + + it("clears suppression on a later busy status without turn-open", () => { + const state = new ExplicitAbortState() + state.event(status("busy"), "/repo") + const id = state.begin("session", "/repo") + state.finish("session", "/repo", id, true) + state.event(status("idle"), "/repo") + state.event(status("busy"), "/repo") + + expect(state.event(close("interrupted"), "/repo")).toBe(true) + }) + + it("does not carry a pending abort into a new busy turn", () => { + const state = new ExplicitAbortState() + state.event(status("busy"), "/repo") + const id = state.begin("session", "/repo") + state.event(status("idle"), "/repo") + state.event(status("busy"), "/repo") + state.finish("session", "/repo", id, true) + + expect(state.event(close("interrupted"), "/repo")).toBe(true) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/git-ops.test.ts b/packages/kilo-vscode/tests/unit/git-ops.test.ts index 05c768c93a3..c90aa3a7c05 100644 --- a/packages/kilo-vscode/tests/unit/git-ops.test.ts +++ b/packages/kilo-vscode/tests/unit/git-ops.test.ts @@ -667,6 +667,20 @@ describe("GitOps", () => { }) }) + it("kills an in-flight exec when its request signal aborts", async () => { + await withRepo(async (cwd) => { + const git = new GitOps({ log: () => undefined, binary: async () => process.execPath }) + const ctl = new AbortController() + const pending = git.execGit(["-e", "setTimeout(() => {}, 5000)"], cwd, { signal: ctl.signal }) + await sleep(25) + ctl.abort() + + const result = await pending + expect(result.code).not.toBe(0) + git.dispose() + }) + }) + it("is safe to call multiple times", () => { const git = ops(async () => "ok") git.dispose() diff --git a/packages/kilo-vscode/tests/unit/indexing-utils.test.ts b/packages/kilo-vscode/tests/unit/indexing-utils.test.ts index ad6551e802d..9e9e43d2573 100644 --- a/packages/kilo-vscode/tests/unit/indexing-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/indexing-utils.test.ts @@ -121,6 +121,11 @@ describe("indexing SSE mapping", () => { }) describe("indexing feature detection", () => { + it("keeps background subagent capability disabled unless the server reports it", () => { + expect(configFeatures().backgroundSubagents).toBe(false) + expect(configFeatures({}, true).backgroundSubagents).toBe(true) + }) + it("enables indexing settings when the indexing plugin is present", () => { expect(configFeatures({ plugin: ["kilo-indexing"] }).indexing).toBe(true) }) diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts index 82621a7e460..ebb3ad18332 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts @@ -87,6 +87,11 @@ function createConnection() { return { data: snapshot } }, }, + experimental: { + capabilities: { + get: async () => ({ data: { backgroundSubagents: true } }), + }, + }, } return { diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts index 58de510b24d..24be2aaf257 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts @@ -191,6 +191,8 @@ function createConnection(client: ReturnType) { }, connect: async () => {}, getClient: () => client, + beginExplicitAbort: () => 1 as number | undefined, + finishExplicitAbort: () => undefined, onEventFiltered: () => () => undefined, onStateChange: (_l: (s: State) => void) => () => undefined, onNotificationDismissed: () => () => undefined, @@ -1238,7 +1240,7 @@ describe("KiloProvider.handleLoadMessages / slim payload", () => { expect(client.aborted).toContainEqual({ sessionID: "s1", directory: "/repo" }) expect(sent).toContainEqual({ type: "sessionCostAlertResolved", sessionID: "s1", limit: 1 }) - expect(sent).toContainEqual({ type: "sessionTurnClosed", sessionID: "s1", reason: "interrupted" }) + expect(sent).not.toContainEqual({ type: "sessionTurnClosed", sessionID: "s1", reason: "interrupted" }) }) it("strips transcript-only metadata before posting messages to the webview", async () => { diff --git a/packages/kilo-vscode/tests/unit/local-diff.test.ts b/packages/kilo-vscode/tests/unit/local-diff.test.ts index 68f8e8f89b8..595d0bf349c 100644 --- a/packages/kilo-vscode/tests/unit/local-diff.test.ts +++ b/packages/kilo-vscode/tests/unit/local-diff.test.ts @@ -352,6 +352,52 @@ describe("diffFile", () => { }) }) + it("reuses cached detail while the summary stamp is unchanged", async () => { + await withRepo(async (dir, base) => { + await fs.writeFile(path.join(dir, "seed.txt"), "seed\ncached\n") + const local = createLocalDiff(git()) + await local.summary(dir, base) + + const first = await local.file(dir, base, "seed.txt") + const second = await local.file(dir, base, "seed.txt") + + expect(second).toBe(first) + }) + }) + + it("does not cache detail that is aborted before Git completes", async () => { + await withRepo(async (dir, base) => { + await fs.writeFile(path.join(dir, "seed.txt"), "seed\ncached\n") + const local = createLocalDiff(git()) + await local.summary(dir, base) + + const ctl = new AbortController() + const pending = local.file(dir, base, "seed.txt", ctl.signal) + ctl.abort() + await expect(pending).rejects.toThrow() + + const result = await local.file(dir, base, "seed.txt") + expect(result?.after).toBe("seed\ncached\n") + }) + }) + + it("invalidates cached detail after the summary stamp changes", async () => { + await withRepo(async (dir, base) => { + await fs.writeFile(path.join(dir, "seed.txt"), "seed\nfirst\n") + const local = createLocalDiff(git()) + await local.summary(dir, base) + const first = await local.file(dir, base, "seed.txt") + + await new Promise((resolve) => setTimeout(resolve, 5)) + await fs.writeFile(path.join(dir, "seed.txt"), "seed\nsecond value\n") + await local.summary(dir, base) + const second = await local.file(dir, base, "seed.txt") + + expect(second).not.toBe(first) + expect(second?.after).toBe("seed\nsecond value\n") + }) + }) + it("does not materialize binary detail from a cached summary", async () => { await withRepo(async (dir, base) => { await fs.writeFile(path.join(dir, "tone.wav"), Buffer.from([0x52, 0x49, 0x46, 0x46, 0x00, 0x01, 0x02, 0x03])) diff --git a/packages/kilo-vscode/tests/unit/sandboxing-settings.test.ts b/packages/kilo-vscode/tests/unit/sandboxing-settings.test.ts index 55c251d07d9..63767afb9a3 100644 --- a/packages/kilo-vscode/tests/unit/sandboxing-settings.test.ts +++ b/packages/kilo-vscode/tests/unit/sandboxing-settings.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test" import { configFeatures } from "../../src/features" import { visible } from "../../webview-ui/src/components/settings/sandboxing" -const features = { indexing: false, sandboxControls: false } +const features = { indexing: false, sandboxControls: false, backgroundSubagents: false } const platform = Object.getOwnPropertyDescriptor(process, "platform") function setPlatform(value: string) { diff --git a/packages/kilo-vscode/tests/unit/semaphore.test.ts b/packages/kilo-vscode/tests/unit/semaphore.test.ts index 697ba146b21..dc64b8b0e5a 100644 --- a/packages/kilo-vscode/tests/unit/semaphore.test.ts +++ b/packages/kilo-vscode/tests/unit/semaphore.test.ts @@ -69,6 +69,22 @@ describe("Semaphore", () => { expect(order).toEqual([1, 2, 3]) }) + it("removes an aborted task from the pending queue", async () => { + const sem = new Semaphore(1) + let release: () => void = () => {} + const first = sem.run(() => new Promise((resolve) => (release = resolve))) + const controller = new AbortController() + const aborted = sem.run(async () => "aborted", controller.signal) + const next = sem.run(async () => "next") + + controller.abort(new Error("cancelled")) + await expect(aborted).rejects.toThrow("cancelled") + release() + + expect(await next).toBe("next") + await first + }) + it("allows full concurrency when limit exceeds task count", async () => { const sem = new Semaphore(10) let running = 0 diff --git a/packages/kilo-vscode/tests/unit/session-parts.test.ts b/packages/kilo-vscode/tests/unit/session-parts.test.ts index 16cebaf7ac3..1c951eb8da3 100644 --- a/packages/kilo-vscode/tests/unit/session-parts.test.ts +++ b/packages/kilo-vscode/tests/unit/session-parts.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "bun:test" -import { mergeOptimisticPart, mergeParts, sameParts } from "../../webview-ui/src/context/session-parts" +import { createStore, produce } from "solid-js/store" +import { isolate, mergeOptimisticPart, mergeParts, sameParts } from "../../webview-ui/src/context/session-parts" import type { Part } from "../../webview-ui/src/types/messages" function text(id: string, value: string, time: { start?: number; end?: number } = {}): Part { @@ -20,6 +21,39 @@ function value(parts: Part[], id: string) { return part.text } +describe("isolate", () => { + it("keeps shared reasoning snapshots independent across session stores", () => { + const shared = { + id: "r1", + messageID: "m1", + type: "reasoning", + text: "Thinking", + time: { start: 1 }, + } satisfies Part + const [first, setFirst] = createStore({ parts: [shared].map(isolate) }) + const [second, setSecond] = createStore({ parts: [shared].map(isolate) }) + + setFirst( + "parts", + produce((parts) => { + const part = parts[0] + if (part?.type === "reasoning") part.text += " once" + }), + ) + setSecond( + "parts", + produce((parts) => { + const part = parts[0] + if (part?.type === "reasoning") part.text += " once" + }), + ) + + expect(first.parts[0]?.type === "reasoning" && first.parts[0].text).toBe("Thinking once") + expect(second.parts[0]?.type === "reasoning" && second.parts[0].text).toBe("Thinking once") + expect(shared.text).toBe("Thinking") + }) +}) + describe("mergeParts", () => { it("keeps a final streamed tail part created after the reconcile snapshot started", () => { const parts = mergeParts( @@ -120,6 +154,31 @@ describe("mergeOptimisticPart", () => { expect(result.parts.map((part) => part.id)).toEqual(["client-text", "server-file"]) expect(result.replaced).toBe("client-file") }) + + it("keeps streamed deltas independent across session stores", () => { + const shared = text("server", "start") + const [first, setFirst] = createStore({ parts: mergeOptimisticPart([], new Set(), shared).parts }) + const [second, setSecond] = createStore({ parts: mergeOptimisticPart([], new Set(), shared).parts }) + + setFirst( + "parts", + produce((parts) => { + const part = parts[0] + if (part?.type === "text") part.text += " chunk" + }), + ) + setSecond( + "parts", + produce((parts) => { + const part = parts[0] + if (part?.type === "text") part.text += " chunk" + }), + ) + + expect(value(first.parts, "server")).toBe("start chunk") + expect(value(second.parts, "server")).toBe("start chunk") + expect(value([shared], "server")).toBe("start") + }) }) describe("sameParts", () => { diff --git a/packages/kilo-vscode/tests/unit/source-controller.test.ts b/packages/kilo-vscode/tests/unit/source-controller.test.ts index 3c14910e70e..c7517fde756 100644 --- a/packages/kilo-vscode/tests/unit/source-controller.test.ts +++ b/packages/kilo-vscode/tests/unit/source-controller.test.ts @@ -346,6 +346,36 @@ describe("SourceController.requestFile", () => { controller.stop() }) + it("does not start queued detail work after the source changes", async () => { + let details = 0 + const workspace: DiffSource = { + descriptor: WORKSPACE_DESC, + async fetch() { + return { diffs: [] } + }, + async fetchFile() { + details++ + return null + }, + } + const session: DiffSource = { + descriptor: SESSION_DESC, + async fetch() { + return { diffs: [] } + }, + } + const { controller } = make({ workspace, "session:s1": session }) + + controller.setContext({ workspaceRoot: "/repo", sessionId: "s1" }) + await controller.activate("workspace") + const request = controller.requestFile("foo.ts") + await controller.activate("session:s1") + await request + + expect(details).toBe(0) + controller.stop() + }) + it("posts null when a pending fetchFile result is invalidated by stop", async () => { let release: () => void = () => {} const workspace: DiffSource = { @@ -459,4 +489,29 @@ describe("SourceController.refresh", () => { controller.stop() }) + + it("runs a forced refresh after an in-flight fetch", async () => { + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + let fetches = 0 + const source: DiffSource = { + descriptor: SESSION_DESC, + async fetch() { + fetches++ + await gate + return { diffs: [] } + }, + } + const { controller } = make({ "session:s1": source }) + controller.setContext({ workspaceRoot: "/repo", sessionId: "s1" }) + const activation = controller.activate("session:s1", { poll: false }) + const refresh = controller.refresh() + release() + await Promise.all([activation, refresh]) + + expect(fetches).toBe(2) + controller.stop() + }) }) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 113e28acd81..cd8fea4548f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -162,14 +162,14 @@ import { import { createEmbeddedTerminalReader } from "./terminal/output" import { focusCurrentTab, renderTab, renderTerminalLayer, renderNewTabButton } from "./tab-rendering" import { useTabScroll } from "./tab-scroll" -import { DiffPanel } from "./DiffPanel" +import { DiffPanelCache } from "./DiffPanelCache" import { PRPanelHost } from "./pr/PRPanelHost" import { createRevertFile } from "./revert-file" import { FullScreenDiffView } from "../diff-viewer/FullScreenDiffView" import { createApplyToLocal } from "./apply-to-local" -import { createWorktreeDiffs, wireDiffId } from "./worktree-diffs" +import { createWorktreeDiffs, diffDataKey, wireDiffId } from "./worktree-diffs" import type { ReviewComment } from "../diff-viewer/review-comments" -import { clearReviewComposer, createReviewComposer } from "../diff-viewer/review-annotations" +import { createReviewComposers } from "./review-composers" import type { SidebarSearchMenuRef } from "./SidebarSearchMenu" import { createSidebarSearch, type SidebarSearchItem } from "./sidebar-search" import { randomColor } from "./section-colors" @@ -213,6 +213,7 @@ import { defaultBase as projectDefaultBase } from "./project/default-base" import "./agent-manager.css" import "./agent-manager-review.css" import { cycleAgent as cycle } from "../src/context/session-agent" +import { createSidebarScrollPreserver } from "./sidebar-scroll" const REVIEW_TAB_ID = "review" interface SetupState { active: boolean @@ -298,27 +299,29 @@ const AgentManagerContent: Component = () => { let sidebarRaf: number | undefined let pendingSidebarWidth: number | undefined const [history, setHistory] = createSignal(false) - /** Project whose sessions the history view is scoped to (multi-project). */ const [historyProject, setHistoryProject] = createSignal() + const [historySwitches, setHistorySwitches] = createSignal([]) const closeHistory = () => { setHistory(false) setHistoryProject(undefined) + setHistorySwitches([]) } - /** Open the sessions view; a project id scopes it and activates that project. */ const openHistory = (pid?: string) => { const scoped = pid !== undefined && multiProject() + if (scoped && (currentProjectId() !== pid || historySwitches().length > 0)) + setHistorySwitches((prev) => (prev.includes(pid) ? prev : [...prev, pid])) + setHistoryProject(scoped ? pid : undefined) + setHistory(true) if (scoped) { - // Activating the target project first lets the shared session store and - // the pick routing operate in that project only. + // Activate the target so the session store and pick routing use that project. vscode.postMessage({ type: "agentManager.activateSelection", target: { projectId: pid, kind: "local" }, } as never) } - setHistoryProject(scoped ? pid : undefined) - setHistory(true) } const [sidePanel, setSidePanel] = createSignal(null) + const [diffMounted, setDiffMounted] = createSignal(false) const diffOpen = () => sidePanel() === SidePanel.Diff const prOpen = () => sidePanel() === SidePanel.PR const activePR = createMemo(() => { @@ -329,6 +332,7 @@ const AgentManagerContent: Component = () => { return { pr, selected, wt: worktrees().find((w) => w.id === selected) } }) const diffs = createWorktreeDiffs(vscode, activeProjectId) + createEffect(on(activeProjectId, diffs.reset, { defer: true })) const diffDatas = diffs.diffDatas const diffLoading = diffs.diffLoading const setDiffLoading = diffs.setDiffLoading @@ -340,7 +344,8 @@ const AgentManagerContent: Component = () => { setReviewActive(false) setSidePanel(SidePanel.Terminal) } - const reviewComposer = createReviewComposer() + const composers = createReviewComposers(currentProjectId) + createEffect(on(activeProjectId, (_next, previous) => previous && composers.clearProject(previous), { defer: true })) const reviewState = createReviewState() const reviewOpenByContext = reviewState.open const setReviewOpenByContext = reviewState.setOpen @@ -534,7 +539,6 @@ const AgentManagerContent: Component = () => { setPendingDelete(null) } createEffect(on(selection, () => cancelPendingDelete(), { defer: true })) - createEffect(on(selection, () => clearReviewComposer(reviewComposer), { defer: true })) createEffect( on( selection, @@ -558,16 +562,6 @@ const AgentManagerContent: Component = () => { if (sel === null) return setReviewOpenForContext(sel, open) } - const reviewComments = createMemo(() => { - const sel = selection() - if (sel === null) return [] as ReviewComment[] - return readReviewComments(reviewCommentsByContext(), currentProjectId() ?? "single", sel) - }) - const setReviewCommentsForSelection = (comments: ReviewComment[]) => { - const sel = selection() - if (sel === null) return - setReviewCommentsByContext((prev) => setReviewComments(prev, currentProjectId() ?? "single", sel, comments)) - } const apply = createApplyToLocal({ vscode, dialog, @@ -728,6 +722,7 @@ const AgentManagerContent: Component = () => { }) createEffect(() => { const ids = new Set(worktrees().map((wt) => wt.id)) + composers.prune(ids) setReviewOpenByContext((prev) => { const next = pruneReviewState(prev, currentProjectId() ?? "single", ids) if (Object.keys(next).length === Object.keys(prev).length) return prev @@ -765,7 +760,7 @@ const AgentManagerContent: Component = () => { const pid = historyProject() if (!pid || !multiProject()) return undefined const sessions = projectSessionsLive()[pid] - if (!sessions) return undefined + if (!sessions) return new Set() return new Set(sessions.filter(isKnownRootSession).map((s) => s.id)) }) @@ -1095,7 +1090,6 @@ const AgentManagerContent: Component = () => { apply: (state) => applyActiveState(state), pruneLive: (ids) => projectLive.prune(ids), }) - const stateHandlers = createProjectStateHandlers({ setMulti: setMultiProject, setProjects: setProjectList, @@ -1111,7 +1105,7 @@ const AgentManagerContent: Component = () => { rename: setRenamingSection, font: (font) => font && setTerminalFont(font), }) - + const preserveSidebarScroll = createSidebarScrollPreserver(() => selection() ?? session.currentSessionID()) /** Apply the active-transition effects of a state payload (data already landed in the store). */ const applyActiveState = (state: AgentManagerStateMessage) => { const switched = applyProjectSwitch(state) @@ -1161,7 +1155,10 @@ const AgentManagerContent: Component = () => { first: () => undefined, close: () => setReviewActive(false), hide: () => setSidePanel(null), - history: () => closeHistory(), + history: () => + state.projectId && historySwitches().includes(state.projectId) + ? setHistorySwitches((prev) => prev.filter((id) => id !== state.projectId)) + : closeHistory(), reset: subagents.reset, }) } @@ -1482,7 +1479,7 @@ const AgentManagerContent: Component = () => { if (msg.type === "agentManager.focusContextRequested") focusCtl.report() if (msg.type === "agentManager.state" && msg.isGitRepo === false && !sessionsLoaded()) setSessionsLoaded(true) - if (msg.type === "agentManager.state") stateHandlers.state(msg) + if (msg.type === "agentManager.state") preserveSidebarScroll(() => stateHandlers.state(msg)) // When a multi-version progress update arrives, mark newly created worktrees as loading if ((msg as { type: string }).type === "agentManager.multiVersionProgress") { @@ -1505,18 +1502,11 @@ const AgentManagerContent: Component = () => { } } - // Set per-session model selection without clearing busy state. - // Used during Phase 1 of multi-version creation so the UI selector - // reflects the correct model as soon as the worktree appears. if ((msg as { type: string }).type === "agentManager.setSessionModel") { const ev = msg as { type: string; sessionId: string; providerID: string; modelID: string } session.setSessionModel(ev.sessionId, ev.providerID, ev.modelID) } - // Handle initial message send for multi-version sessions. - // The extension creates the worktrees/sessions, then asks the webview - // to send the prompt through the normal KiloProvider sendMessage path. - // Once the message is sent, clear the loading state for that worktree. if ((msg as { type: string }).type === "agentManager.sendInitialMessage") { const ev = msg as unknown as AgentManagerSendInitialMessage @@ -1667,6 +1657,17 @@ const AgentManagerContent: Component = () => { // The composite id (ctx#scope) the extension keys diff data by. const diffScopeId = review.id + const reviewComments = createMemo(() => { + const key = diffScopeId() + if (!key) return [] as ReviewComment[] + return readReviewComments(reviewCommentsByContext(), currentProjectId() ?? "single", key) + }) + const setReviewCommentsForSelection = (comments: ReviewComment[]) => { + const key = diffScopeId() + if (!key) return + setReviewCommentsByContext((prev) => setReviewComments(prev, currentProjectId() ?? "single", key, comments)) + } + const diffScopeControls = (compact: boolean) => ( { tabFocus.restore() } - // Data for the review tab / side panel: keyed by the composite diff id - // (ctx#scope) the extension pushes, so each scope keeps its own file set and - // switching back to a fetched scope is instant. const reviewDiffs = createMemo(() => { const data = diffDatas() const key = diffScopeId() if (!key) return [] - return data[key] ?? [] + return data[diffDataKey(activeProjectId(), key)] ?? [] }) const diffSessionKey = createMemo(() => diffScopeId() ?? "") - // Source-level notice for the active composite id (e.g. snapshots disabled - // for the Session scope), shown as a banner instead of the empty state. const diffNotice = createMemo(() => { const key = diffScopeId() if (!key) return undefined - return diffNotices()[key] + return diffNotices()[diffDataKey(activeProjectId(), key)] }) const requestDiffFile = (file: string) => { @@ -1759,9 +1755,12 @@ const AgentManagerContent: Component = () => { } const diffFileLoadingForCurrent = createMemo(() => diffs.diffFileLoadingFor(diffScopeId)) + const diffLoadingForCurrent = createMemo(() => diffs.diffLoadingFor(diffScopeId)) const revertCtl = createRevertFile(diffScopeId, diffCtx, () => review.scope(), vscode, showToast, t, activeProjectId) + createEffect(() => diffOpen() && setDiffMounted(true)) + const handleShowKeyboardShortcuts = () => { const categories = buildShortcutCategories(kb(), t) dialog.show(() => ( @@ -2399,9 +2398,6 @@ const AgentManagerContent: Component = () => {
- {/* Tab bar — full version with tabs renders when a section is selected - and has tabs; otherwise a minimal version still renders so the - sidebar toggle button stays at a fixed position. */} { /> - {/* Terminal overlay is scoped to the main pane so it does not cover the tab bar or side panel. */}
- {/* Chat/terminal + side diff panel. Keep it mounted under the - review tab so live xterm canvases never leave the paint tree. */}
- {/* Keep terminal tabs mounted so output streams across worktree switches. */} {renderTerminalLayer({ state: terms, onFocusPrompt: focusCtl.focus, onFocusChange: focusCtl.report, })} - {/* Session-less context (e.g. a worktree mid-provisioning): the - empty state lives in the main pane so the side terminal - panel can render next to it. */}
{
- {/* One inspector host for all right-side modes. It stays - mounted while a side terminal is alive — hidden via - .am-side-host-hidden (absolute + opacity), never - unmounted, so xterm render loops keep streaming. */} - 0 || subagents.tabs().length > 0}> + 0 || subagents.tabs().length > 0} + >
{ />
- - metrics.track("send_review_comments", "side_review")} - onClose={metrics.click("side_review_close", "side_review", () => setSidePanel(null))} - onExpand={ - selection() !== null - ? metrics.click("fullscreen_review", "side_review", openReviewTab, { action: "open" }) - : undefined - } - onRequestDiff={requestDiffFile} - onOpenFile={(file, line) => { - const id = diffCtx() - if (id) - vscode.postMessage({ type: "agentManager.openFile", sessionId: id, filePath: file, line }) - }} - onOpenDocument={documentInspector.open} - onRevertFile={metrics.use("revert_file", "side_review", revertCtl.revert)} - revertingFiles={revertCtl.reverting()} - activeTerminalId={terms.activeId()} - /> - + diffOpen() && !history() && !reviewActive()} + data={diffDatas} + loading={(key) => diffs.diffLoadingFor(() => key)} + loadingFiles={(key) => diffs.diffFileLoadingFor(() => key)} + notice={(key) => diffNotices()[diffDataKey(activeProjectId(), key)]} + comments={(key) => + readReviewComments(reviewCommentsByContext(), currentProjectId() ?? "single", key) + } + setComments={(key, comments) => + setReviewCommentsByContext((prev) => + setReviewComments(prev, currentProjectId() ?? "single", key, comments), + ) + } + composer={composers.get} + lead={() => diffScopeControls(true)} + canRevert={scopeCapabilities(review.scope()).revert} + diffStyle={diffStyle.style()} + onDiffStyleChange={setSharedDiffStyle} + markdownRender={markdown.render()} + onMarkdownRenderChange={markdown.update} + onSendClick={() => metrics.track("send_review_comments", "side_review")} + onClose={metrics.click("side_review_close", "side_review", () => setSidePanel(null))} + onExpand={ + selection() !== null + ? metrics.click("fullscreen_review", "side_review", openReviewTab, { action: "open" }) + : undefined + } + onRequestDiff={diffs.requestDiffFile} + onOpenFile={(ctx, file, line) => + vscode.postMessage({ type: "agentManager.openFile", sessionId: ctx, filePath: file, line }) + } + onOpenDocument={documentInspector.open} + onRevertFile={(key, ctx, file) => { + metrics.track("revert_file", "side_review") + revertCtl.revertFor(key, ctx, review.scope(), file) + }} + revertingFiles={revertCtl.revertingFor} + activeTerminalId={terms.activeId()} + contexts={() => new Set(worktrees().map((wt) => wt.id))} + onEvict={(key) => { + composers.drop(key) + diffs.drop(key) + }} + /> {
{ canComment={scopeCapabilities(review.scope()).comments} comments={reviewComments()} onCommentsChange={setReviewCommentsForSelection} - composer={reviewComposer} + composer={composers.get(`${activeProjectId() ?? "single"}\0${diffScopeId() ?? ""}`)} onSendAll={closeReviewTab} onSendClick={() => metrics.track("send_review_comments", "fullscreen_review")} diffStyle={diffStyle.style()} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx index 9513aacae6a..4dbb6611ac9 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx @@ -1,4 +1,13 @@ -import { type Component, createSignal, createMemo, Show, createEffect, on, type JSXElement } from "solid-js" +import { + type Component, + createSignal, + createMemo, + Show, + createEffect, + createRenderEffect, + on, + type JSXElement, +} from "solid-js" import type { VirtualizerHandle } from "virtua/solid" import { Diff } from "@kilocode/kilo-ui/diff" import { Accordion } from "@kilocode/kilo-ui/accordion" @@ -8,7 +17,6 @@ import { DiffChanges } from "@kilocode/kilo-ui/diff-changes" import { Icon } from "@kilocode/kilo-ui/icon" import { Button } from "@kilocode/kilo-ui/button" import { IconButton } from "@kilocode/kilo-ui/icon-button" -import { Spinner } from "@kilocode/kilo-ui/spinner" import { Tooltip, TooltipKeybind } from "@kilocode/kilo-ui/tooltip" import type { DiffLineAnnotation, AnnotationSide, SelectedLineRange } from "@pierre/diffs" import type { WorktreeFileDiff } from "../src/types/messages" @@ -74,6 +82,7 @@ const DIFF_NOTICE_KEYS: Record = { interface DiffPanelProps { diffs: WorktreeFileDiff[] loading: boolean + active?: boolean loadingFiles?: Set sessionId?: string sessionKey?: string @@ -159,6 +168,7 @@ export const DiffPanel: Component = (props) => { }, ), ) + const setOpen = (files: string[] | ((prev: string[]) => string[])) => { const key = props.sessionKey ?? "" const current = open() @@ -197,6 +207,20 @@ export const DiffPanel: Component = (props) => { // so pierre's annotation cache doesn't invalidate and destroy the textarea. let draftMeta: AnnotationMeta | null = composer().draft let editMeta: AnnotationMeta | null = composer().edit + createRenderEffect( + on( + () => props.active, + (active) => { + if (!active) return + const value = reviewComposerDraft(composer()) + const edit = reviewComposerEdit(composer()) + setDraft(value) + setEditing(edit) + draftMeta = composer().draft + editMeta = composer().edit + }, + ), + ) // Ref to the scrollable container — used to preserve scroll position when // annotation changes cause pierre to fully re-render diffs @@ -249,6 +273,7 @@ export const DiffPanel: Component = (props) => { on( () => props.sessionKey, () => { + if (props.active === false) return setDraft(null) draftMeta = null setEditing(null) @@ -264,7 +289,7 @@ export const DiffPanel: Component = (props) => { diffs: () => props.diffs, open, loading: () => props.loadingFiles, - send: () => props.onRequestDiff, + send: () => (props.active === false ? undefined : props.onRequestDiff), }) // --- CRUD --- @@ -327,6 +352,7 @@ export const DiffPanel: Component = (props) => { on( () => [props.diffs, comments()] as const, ([diffs, current]) => { + if (props.active === false) return const valid = sanitizeReviewComments(current, diffs) if (valid.length !== current.length) { setComments(valid) @@ -392,8 +418,10 @@ export const DiffPanel: Component = (props) => { const result = buildFileAnnotations(file, commentsByFile().get(file) ?? [], editing(), draft(), draftMeta, editMeta) draftMeta = result.draftMeta editMeta = result.editMeta - composer().draft = draft() ? draftMeta : null - composer().edit = editing() ? editMeta : null + if (props.active !== false) { + composer().draft = draft() ? draftMeta : null + composer().edit = editing() ? editMeta : null + } return result.annotations } @@ -553,7 +581,6 @@ export const DiffPanel: Component = (props) => {
- {t("session.review.loadingChanges")}
@@ -714,10 +741,7 @@ export const DiffPanel: Component = (props) => { fallback={
Diff preview loads on demand.}> - <> - - Loading diff... - + Loading diff...
} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanelCache.tsx b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanelCache.tsx new file mode 100644 index 00000000000..0bf696f83c9 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanelCache.tsx @@ -0,0 +1,129 @@ +import { For, createEffect, createMemo, createSignal, type Accessor, type Component, type JSX } from "solid-js" +import type { WorktreeFileDiff } from "../src/types/messages" +import type { ReviewComment } from "../diff-viewer/review-comments" +import type { ReviewComposer } from "../diff-viewer/review-annotations" +import { DiffPanel } from "./DiffPanel" +import { diffDataKey } from "./worktree-diffs" + +const CACHE_SIZE = 4 + +interface Entry { + key: string + cacheKey: string + ctx: string + used: number +} + +interface Props { + current: Accessor + context: Accessor + project: Accessor + active: Accessor + onEvict?: (key: string) => void + contexts: Accessor> + data: Accessor> + loading: (key: string) => boolean + loadingFiles: (key: string) => Set + notice: (key: string) => string | undefined + comments: (ctx: string) => ReviewComment[] + setComments: (ctx: string, comments: ReviewComment[]) => void + composer: (key: string) => ReviewComposer + lead: () => JSX.Element + canRevert: boolean + diffStyle: "unified" | "split" + onDiffStyleChange: (style: "unified" | "split") => void + markdownRender: boolean + onMarkdownRenderChange: (render: boolean) => void + onSendClick: () => void + onClose: () => void + onExpand?: () => void + onRequestDiff: (key: string, file: string) => void + onOpenFile: (ctx: string, file: string, line?: number) => void + onOpenDocument: (file: string) => void + onRevertFile: (key: string, ctx: string, file: string) => void + revertingFiles: (key: string) => Set + activeTerminalId?: string +} + +export const DiffPanelCache: Component = (props) => { + const [entries, setEntries] = createSignal([]) + let used = 0 + + createEffect(() => { + const contexts = props.contexts() + setEntries((prev) => { + const next = prev.filter((entry) => entry.ctx === "local" || contexts.has(entry.ctx)) + for (const item of prev) if (!next.includes(item)) props.onEvict?.(item.cacheKey) + return next + }) + }) + + createEffect(() => { + if (!props.active()) return + const key = props.current() + const ctx = props.context() + const project = props.project() ?? "single" + if (!key || !ctx) return + const cacheKey = `${project}\0${key}` + setEntries((prev) => { + const prefix = `${project}\0` + const scoped = prev.filter((item) => item.cacheKey.startsWith(prefix)) + const current = scoped.find((item) => item.cacheKey === cacheKey) + if (current) { + current.used = ++used + for (const item of prev) if (!scoped.includes(item)) props.onEvict?.(item.cacheKey) + return scoped + } + const next = [...scoped, { key, cacheKey, ctx, used: ++used }] + if (next.length <= CACHE_SIZE) { + for (const item of prev) if (!next.includes(item)) props.onEvict?.(item.cacheKey) + return next + } + const oldest = next.reduce((entry, item) => (item.used < entry.used ? item : entry)) + const result = next.filter((item) => item !== oldest) + props.onEvict?.(oldest.cacheKey) + for (const item of prev) if (!result.includes(item)) props.onEvict?.(item.cacheKey) + return result + }) + }) + + return ( + + {(entry) => { + const active = createMemo( + () => props.active() && `${props.project() ?? "single"}\0${props.current()}` === entry.cacheKey, + ) + return ( +
+ props.setComments(entry.key, comments)} + composer={props.composer(entry.cacheKey)} + onSendClick={props.onSendClick} + onClose={props.onClose} + onExpand={props.onExpand} + onRequestDiff={(file) => props.onRequestDiff(entry.key, file)} + onOpenFile={(file, line) => props.onOpenFile(entry.ctx, file, line)} + onOpenDocument={props.onOpenDocument} + onRevertFile={(file) => props.onRevertFile(entry.key, entry.ctx, file)} + revertingFiles={props.revertingFiles(entry.key)} + activeTerminalId={props.activeTerminalId} + /> +
+ ) + }} +
+ ) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css index 34ea4b246f4..d3c8961a14d 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css @@ -1851,6 +1851,24 @@ body.am-wt-dragging-active * { overflow: hidden; } +.am-diff-panel-cache { + position: absolute; + inset: 0; + display: flex; + min-width: 0; + min-height: 0; + opacity: 0; + pointer-events: none; + z-index: 0; + background: var(--surface-base); +} + +.am-diff-panel-cache-active { + opacity: 1; + pointer-events: auto; + z-index: 2; +} + .am-diff-panel-wrapper > [data-component="resize-handle"]::after { background: var(--surface-interactive-base); } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/apply-to-local.tsx b/packages/kilo-vscode/webview-ui/agent-manager/apply-to-local.tsx index d57c3a39c50..5fee2b5b3fc 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/apply-to-local.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/apply-to-local.tsx @@ -15,6 +15,7 @@ import { showToast } from "@kilocode/kilo-ui/toast" import { groupApplyConflicts } from "./apply-conflicts" import { ApplyDialog } from "./ApplyDialog" import { composeDiffId } from "./diff-scope-state" +import { diffDataKey } from "./worktree-diffs" import type { tracker } from "./telemetry" import type { useDialog } from "@kilocode/kilo-ui/context/dialog" import type { useLanguage } from "../src/context/language" @@ -74,7 +75,7 @@ export function createApplyToLocal(opts: ApplyToLocalOptions) { const applyDiffs = createMemo(() => { const key = applyDiffKey() if (!key) return [] as WorktreeFileDiff[] - return diffDatas()[key] ?? ([] as WorktreeFileDiff[]) + return diffDatas()[diffDataKey(opts.projectId?.(), key)] ?? ([] as WorktreeFileDiff[]) }) const applyStateForTarget = createMemo(() => { diff --git a/packages/kilo-vscode/webview-ui/agent-manager/project/review-state.ts b/packages/kilo-vscode/webview-ui/agent-manager/project/review-state.ts index ce0aa9e51c6..158b91cb54e 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/project/review-state.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/project/review-state.ts @@ -50,8 +50,9 @@ export function pruneReviewState( ): Record { return Object.fromEntries( Object.entries(values).filter(([key]) => { - const [owner, context] = key.split(":") - return owner !== project || context === "local" || contexts.has(context) + const [owner, value] = key.split(":") + const context = value?.split("#", 1)[0] + return owner !== project || context === "local" || (context !== undefined && contexts.has(context)) }), ) } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/revert-file.ts b/packages/kilo-vscode/webview-ui/agent-manager/revert-file.ts index 3f2a5285180..1b3605fae42 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/revert-file.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/revert-file.ts @@ -21,38 +21,45 @@ export function createRevertFile( projectId?: Accessor, ) { const [files, setFiles] = createSignal>>({}) + const key = (project: string | undefined, scope: string) => `${project ?? "single"}\0${scope}` const reverting = createMemo(() => { const id = diffScopeId() if (!id) return new Set() - return files()[id] ?? new Set() + return files()[key(projectId?.(), id)] ?? new Set() }) - function revert(file: string) { - const id = diffScopeId() - const context = ctx() + const revertingFor = (id: string) => files()[key(projectId?.(), id)] ?? new Set() + + function revertFor(id: string | undefined, context: string | undefined, source: string, file: string) { if (!id || !context) return + const data = key(projectId?.(), id) setFiles((prev) => { - const set = new Set(prev[id] ?? []) + const set = new Set(prev[data] ?? []) set.add(file) - return { ...prev, [id]: set } + return { ...prev, [data]: set } }) vscode.postMessage({ type: "agentManager.revertWorktreeFile", projectId: projectId?.(), sessionId: context, file, - scope: scope(), + scope: source, }) } + function revert(file: string) { + revertFor(diffScopeId(), ctx(), scope(), file) + } + function onResult(ev: AgentManagerRevertWorktreeFileResultMessage) { + const data = key(ev.projectId, ev.sessionId) setFiles((prev) => { - const set = new Set(prev[ev.sessionId] ?? []) + const set = new Set(prev[data] ?? []) set.delete(ev.file) const next = { ...prev } - if (set.size === 0) delete next[ev.sessionId] - else next[ev.sessionId] = set + if (set.size === 0) delete next[data] + else next[data] = set return next }) if (ev.status === "success") { @@ -62,5 +69,5 @@ export function createRevertFile( } } - return { reverting, revert, onResult } + return { reverting, revertingFor, revert, revertFor, onResult } } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/review-composers.ts b/packages/kilo-vscode/webview-ui/agent-manager/review-composers.ts new file mode 100644 index 00000000000..a82f0781be0 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/review-composers.ts @@ -0,0 +1,42 @@ +import type { Accessor } from "solid-js" +import { createReviewComposer, type ReviewComposer } from "../diff-viewer/review-annotations" + +export function createReviewComposers(project: Accessor) { + const values = new Map() + + const get = (key: string) => { + const current = values.get(key) + if (current) return current + const next = createReviewComposer() + values.set(key, next) + return next + } + + const clear = (ctx: string | null) => { + if (!ctx) return + const prefix = `${project() ?? "single"}\0${ctx}` + for (const key of values.keys()) { + if (key === prefix || key.startsWith(`${prefix}#`)) values.delete(key) + } + } + + const drop = (key: string) => values.delete(key) + + const clearProject = (id: string) => { + const prefix = `${id}\0` + for (const key of values.keys()) { + if (key.startsWith(prefix)) values.delete(key) + } + } + + const prune = (contexts: Set) => { + const prefix = `${project() ?? "single"}\0` + for (const key of values.keys()) { + if (!key.startsWith(prefix)) continue + const ctx = key.slice(prefix.length).split("#", 1)[0] + if (ctx !== "local" && !contexts.has(ctx)) values.delete(key) + } + } + + return { get, clear, drop, clearProject, prune } +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/sidebar-scroll.ts b/packages/kilo-vscode/webview-ui/agent-manager/sidebar-scroll.ts new file mode 100644 index 00000000000..7c81604c294 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/sidebar-scroll.ts @@ -0,0 +1,38 @@ +type Entry = { + el: HTMLElement + top: number +} + +export function createSidebarScrollPreserver( + active: () => string | null | undefined = () => undefined, + root: ParentNode = document, + schedule: typeof requestAnimationFrame = requestAnimationFrame, + cancel: typeof cancelAnimationFrame = cancelAnimationFrame, +) { + let frame: number | undefined + let inner: number | undefined + + return (fn: () => void): void => { + if (frame !== undefined) cancel(frame) + if (inner !== undefined) cancel(inner) + + const prior = active() + const scrolls: Entry[] = [...root.querySelectorAll(".am-worktree-list, .am-projects-list")].map( + (el) => ({ + el, + top: el.scrollTop, + }), + ) + fn() + frame = schedule(() => { + frame = undefined + inner = schedule(() => { + inner = undefined + if (active() !== prior) return + for (const item of scrolls) { + if (item.el.isConnected && item.top > 0 && item.el.scrollTop === 0) item.el.scrollTop = item.top + } + }) + }) + } +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx b/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx index 54ef6689981..a58e421de62 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx @@ -334,7 +334,7 @@ export const TerminalTab: Component = (props) => { if (typeof event.data === "string") { if (!replay.output(event.data)) { input.clear() - next.close(1009, "terminal replay exceeded limit") + next.close(4009, "terminal replay exceeded limit") return } scheduleFlush() @@ -345,7 +345,7 @@ export const TerminalTab: Component = (props) => { if (replay.frame(bytes)) return if (!replay.output(bytes)) { input.clear() - next.close(1009, "terminal replay exceeded limit") + next.close(4009, "terminal replay exceeded limit") return } scheduleFlush() diff --git a/packages/kilo-vscode/webview-ui/agent-manager/worktree-diffs.ts b/packages/kilo-vscode/webview-ui/agent-manager/worktree-diffs.ts index 60ba7cb5991..350ba0ea5cd 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/worktree-diffs.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/worktree-diffs.ts @@ -29,15 +29,43 @@ export function wireDiffId(id: string) { return { sessionId: ctx, scope, diffSessionId: sessionId } } +export function diffDataKey(project: string | undefined, id: string): string { + return `${project ?? "single"}\0${id}` +} + export function createWorktreeDiffs( vscode: ReturnType, project: () => string | undefined = () => undefined, ) { const [diffDatas, setDiffDatas] = createSignal>({}) - const [diffLoading, setDiffLoading] = createSignal(false) + const [diffLoadings, setDiffLoadings] = createSignal>({}) + const diffLoading = () => Object.keys(diffLoadings()).length > 0 const [diffNotices, setDiffNotices] = createSignal>({}) const [diffFileLoading, setDiffFileLoading] = createSignal>>({}) + const key = (id: string) => diffDataKey(project(), id) + + const reset = () => { + setDiffDatas({}) + setDiffLoadings({}) + setDiffNotices({}) + setDiffFileLoading({}) + } + + const drop = (id: string) => { + const data = id.includes("\0") ? id : key(id) + const remove = >(prev: T): T => { + if (!(data in prev)) return prev + const next = { ...prev } + delete next[data] + return next + } + setDiffDatas(remove) + setDiffLoadings(remove) + setDiffNotices(remove) + setDiffFileLoading(remove) + } + const setDiffFilePending = (sessionId: string, file: string, value: boolean) => { setDiffFileLoading((prev) => { const session = prev[sessionId] ?? {} @@ -66,20 +94,21 @@ export function createWorktreeDiffs( /** Lazily load a single file's full diff for the given composite diff id. */ const requestDiffFile = (id: string, file: string) => { - if (diffFileLoading()[id]?.[file]) return - setDiffFilePending(id, file, true) + const data = key(id) + if (diffFileLoading()[data]?.[file]) return + setDiffFilePending(data, file, true) vscode.postMessage({ type: "agentManager.requestWorktreeDiffFile", projectId: project(), file, ...wireDiffId(id) }) } /** Files the backend flagged as stale in a merged update need a fresh fetch. */ - const refreshStaleDiffs = (id: string, files: Set) => { - const loading = diffFileLoading()[id] ?? {} + const refreshStaleDiffs = (id: string, files: Set, data = key(id), owner = project()) => { + const loading = diffFileLoading()[data] ?? {} for (const file of files) { if (loading[file]) continue - setDiffFilePending(id, file, true) + setDiffFilePending(data, file, true) vscode.postMessage({ type: "agentManager.requestWorktreeDiffFile", - projectId: project(), + projectId: owner, file, ...wireDiffId(id), }) @@ -90,53 +119,79 @@ export function createWorktreeDiffs( const diffFileLoadingFor = (sessionId: Accessor) => { const id = sessionId() if (!id) return new Set() - return new Set(Object.keys(diffFileLoading()[id] ?? {})) + return new Set(Object.keys(diffFileLoading()[key(id)] ?? {})) + } + + /** Initial summary loading for one composite diff id. Cached results stay visible while refreshing. */ + const diffLoadingFor = (sessionId: Accessor) => { + const id = sessionId() + if (!id) return false + const data = key(id) + return diffLoadings()[data] === true && !(data in diffDatas()) } // Backend messages. const onWorktreeDiff = (ev: AgentManagerWorktreeDiffMessage) => { + const data = diffDataKey(ev.projectId, ev.sessionId) let staleFiles: Set | undefined setDiffDatas((prev) => { - const existing = prev[ev.sessionId] + const existing = prev[data] const merged = existing ? mergeWorktreeDiffs(existing, ev.diffs) : { diffs: ev.diffs, stale: new Set() } staleFiles = merged.stale const next = merged.diffs if (existing && existing.length === next.length && existing.every((old, i) => old === next[i])) return prev - return { ...prev, [ev.sessionId]: next } + return { ...prev, [data]: next } }) - if (staleFiles) refreshStaleDiffs(ev.sessionId, staleFiles) + if (staleFiles) refreshStaleDiffs(ev.sessionId, staleFiles, data, ev.projectId) } const onWorktreeDiffFile = (ev: AgentManagerWorktreeDiffFileMessage) => { + const data = diffDataKey(ev.projectId, ev.sessionId) if (ev.diff) { setDiffDatas((prev) => { - const existing = prev[ev.sessionId] ?? [] + const existing = prev[data] ?? [] const next = existing.map((item) => (item.file === ev.diff!.file ? ev.diff! : item)) - return { ...prev, [ev.sessionId]: next } + return { ...prev, [data]: next } }) - setDiffFilePending(ev.sessionId, ev.diff.file, false) + setDiffFilePending(data, ev.diff.file, false) return } - setDiffFilePending(ev.sessionId, ev.file, false) + setDiffFilePending(data, ev.file, false) } const onWorktreeDiffLoading = (ev: AgentManagerWorktreeDiffLoadingMessage) => { - setDiffLoading(ev.loading) + const data = diffDataKey(ev.projectId, ev.sessionId) + // One source is active per project. Replacing the map on start also clears + // an interrupted source whose stale completion is intentionally discarded. + if (ev.loading) { + setDiffLoadings({ [data]: true }) + return + } + setDiffLoadings((prev) => { + if (!prev[data]) return prev + const next = { ...prev } + delete next[data] + return next + }) } const onWorktreeDiffNotice = (ev: AgentManagerWorktreeDiffNoticeMessage) => { - setDiffNotices((prev) => ({ ...prev, [ev.sessionId]: ev.notice })) + setDiffNotices((prev) => ({ ...prev, [diffDataKey(ev.projectId, ev.sessionId)]: ev.notice })) } return { diffDatas, diffLoading, - setDiffLoading, + setDiffLoading: (loading: boolean) => setDiffLoadings(loading ? diffLoadings() : {}), diffNotices, requestDiffFile, refreshStaleDiffs, diffFileLoadingFor, + diffLoadingFor, + diffDataKey, + drop, + reset, onWorktreeDiff, onWorktreeDiffFile, onWorktreeDiffLoading, diff --git a/packages/kilo-vscode/webview-ui/diff-viewer/diff-requests.ts b/packages/kilo-vscode/webview-ui/diff-viewer/diff-requests.ts index 904ceca13fc..8d517795668 100644 --- a/packages/kilo-vscode/webview-ui/diff-viewer/diff-requests.ts +++ b/packages/kilo-vscode/webview-ui/diff-viewer/diff-requests.ts @@ -13,6 +13,7 @@ interface DiffRequestOptions { export function createDiffRequests(opts: DiffRequestOptions) { const requested = new Map() + let active = false createEffect( on( @@ -36,8 +37,17 @@ export function createDiffRequests(opts: DiffRequestOptions) { createEffect( on( - () => [opts.open(), opts.diffs(), opts.loading()] as const, + () => [opts.open(), opts.diffs(), opts.loading(), opts.send()] as const, ([open, diffs]) => { + if (!opts.send()) { + requested.clear() + active = false + return + } + if (!active) { + requested.clear() + active = true + } const files = new Set(open) for (const file of requested.keys()) { if (!files.has(file)) requested.delete(file) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/TaskToolExpanded.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/TaskToolExpanded.tsx index 3a8d4f72daf..568245bcc6c 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/TaskToolExpanded.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/TaskToolExpanded.tsx @@ -20,7 +20,8 @@ import { createAutoScroll } from "@kilocode/kilo-ui/hooks" import { useSession } from "../../context/session" import { useVSCode } from "../../context/vscode" import { useWorktreeMode } from "../../context/worktree-mode" -import { childID } from "../../context/session-utils" +import { childID, latestTaskPart } from "../../context/session-utils" +import { useConfig } from "../../context/config" import { openSubagent } from "./open-subagent" import { showChildPromotion, taskResult, taskRunning, taskVisible } from "./task-tool-state" @@ -28,6 +29,7 @@ const TaskToolRenderer: Component = (props) => { const i18n = useI18n() const language = useLanguage() const session = useSession() + const { features } = useConfig() const vscode = useVSCode() const worktree = useWorktreeMode() @@ -45,7 +47,13 @@ const TaskToolRenderer: Component = (props) => { props.partMetadata as Record | undefined, props.metadata as Record | undefined, session.allStatusMap(), + features().backgroundSubagents, props.readonly, + latestTaskPart( + props.partID, + childSessionId(), + session.currentSessionID() ? session.getSessionToolParts(session.currentSessionID()!) : [], + ), ), ) @@ -168,7 +176,7 @@ const TaskToolRenderer: Component = (props) => {
- + | undefined, state: Record | undefined, status: Record, + latest: boolean, ) { - if (!id) return false + if (!id || !latest) return false if (part?.background === true || state?.background === true) return false return status[id]?.type === "busy" || status[id]?.type === "retry" } @@ -20,9 +21,11 @@ export function showChildPromotion( part: Record | undefined, state: Record | undefined, status: Record, + enabled: boolean | undefined, readonly: boolean | undefined, + latest: boolean, ) { - return !readonly && childForeground(id, part, state, status) + return enabled === true && !readonly && childForeground(id, part, state, status, latest) } export function taskVisible(open: boolean | undefined, id: string | undefined) { diff --git a/packages/kilo-vscode/webview-ui/src/context/config.tsx b/packages/kilo-vscode/webview-ui/src/context/config.tsx index 53b3bfafc3e..4fc09d1e8da 100644 --- a/packages/kilo-vscode/webview-ui/src/context/config.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/config.tsx @@ -88,7 +88,11 @@ export const ConfigProvider: ParentComponent = (props) => { const [projectConfig, setProjectConfig] = createSignal({}) const [collections, setCollections] = createSignal({}) const [settings, setSettings] = createSignal>({}) - const [features, setFeatures] = createSignal({ indexing: false, sandboxControls: false }) + const [features, setFeatures] = createSignal({ + indexing: false, + sandboxControls: false, + backgroundSubagents: false, + }) const [loading, setLoading] = createSignal(true) const [draft, setDraft] = createSignal>({}) const [globalDraft, setGlobalDraft] = createSignal>({}) diff --git a/packages/kilo-vscode/webview-ui/src/context/session-parts.ts b/packages/kilo-vscode/webview-ui/src/context/session-parts.ts index 883a2b71483..b025feabd7d 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session-parts.ts +++ b/packages/kilo-vscode/webview-ui/src/context/session-parts.ts @@ -1,5 +1,9 @@ import type { Part } from "../types/messages" +export function isolate(part: Part): Part { + return { ...part } +} + function stream(part: Part): part is Extract { return part.type === "text" || part.type === "reasoning" } @@ -27,10 +31,11 @@ export function sameParts(local: Part[] = [], snapshot: Part[] = []): boolean { export function mergeOptimisticPart(current: Part[], ids: ReadonlySet, part: Part) { const index = current.findIndex((item) => ids.has(item.id) && item.type === part.type) - if (index < 0) return { parts: [...current, part] } + const copy = isolate(part) + if (index < 0) return { parts: [...current, copy] } const old = current[index]! const next = current.slice() - next[index] = part + next[index] = copy return { parts: next, replaced: old.id } } diff --git a/packages/kilo-vscode/webview-ui/src/context/session-utils.ts b/packages/kilo-vscode/webview-ui/src/context/session-utils.ts index b783496f7a7..84212c2c4b6 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session-utils.ts +++ b/packages/kilo-vscode/webview-ui/src/context/session-utils.ts @@ -105,6 +105,7 @@ type ToolState = { } type TaskPart = { + id?: string type: string tool?: string metadata?: { sessionId?: string } @@ -116,6 +117,11 @@ export function childID(part: TaskPart): string | undefined { return part.metadata?.sessionId ?? part.state?.metadata?.sessionId } +export function latestTaskPart(partID: string | undefined, child: string | undefined, parts: readonly TaskPart[]) { + if (!partID || !child) return false + return parts.findLast((part) => childID(part) === child)?.id === partID +} + function stringField(value: unknown): string | undefined { return typeof value === "string" ? value : undefined } diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index 64c1753c5f7..54841acdae6 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -79,7 +79,7 @@ import { getAgentModel } from "./session-model-store" import { resolveMessagePrefs } from "./session-preferences" import { errorIDs, preserveSessionErrors, withoutResolvedSessionErrors } from "./session-errors" import { PartStash } from "./part-stash" -import { mergeOptimisticPart, mergeParts } from "./session-parts" +import { isolate, mergeOptimisticPart, mergeParts } from "./session-parts" import { mergeMessages, sameReconcileShape } from "./session-merge" import { state as todoState } from "./todo-revert" import { sessionVariantKeys, transferVariants, variantKey } from "./session-variant-store" @@ -1457,7 +1457,7 @@ export const SessionProvider: ParentComponent = (props) => { const cutoff = Math.max(0, messages.length - 15) for (let i = 0; i < messages.length; i++) { const msg = messages[i]! - const parts = msg.parts ?? [] + const parts = msg.parts?.map(isolate) ?? [] if (mode === "reconcile" && store.parts[msg.id] && !optimisticParts.has(msg.id)) { const merged = mergeParts(store.parts[msg.id], parts, input.since ?? Number.POSITIVE_INFINITY) setStore("parts", msg.id, reconcile(merged, { key: "id" })) @@ -1546,7 +1546,7 @@ export const SessionProvider: ParentComponent = (props) => { if (message.parts && message.parts.length > 0) { optimisticParts.delete(message.id) stash.remove(message.id) - setStore("parts", message.id, message.parts) + setStore("parts", message.id, message.parts.map(isolate)) } rebuildToolParts(message.sessionID, store.messages[message.sessionID] ?? []) } @@ -1624,7 +1624,7 @@ export const SessionProvider: ParentComponent = (props) => { } } else { // Add new part - list.push(part) + list.push(isolate(part)) } }), ) @@ -2067,7 +2067,7 @@ export const SessionProvider: ParentComponent = (props) => { setStore("messages", key, messages) for (const msg of messages) { if (msg.parts && msg.parts.length > 0) { - setStore("parts", msg.id, msg.parts) + setStore("parts", msg.id, msg.parts.map(isolate)) } } rebuildToolParts(key, messages) diff --git a/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx b/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx index 00cf1e48759..d682b3ab6bb 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx @@ -337,6 +337,7 @@ const ConfigWrapper: ParentComponent<{ return { indexing: props.features?.indexing ?? hasIndexingPlugin(config.plugin ?? []), sandboxControls: props.features?.sandboxControls ?? false, + backgroundSubagents: props.features?.backgroundSubagents ?? false, } }) diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/config.ts b/packages/kilo-vscode/webview-ui/src/types/messages/config.ts index ccca1426483..de4014161a1 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/config.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/config.ts @@ -173,4 +173,5 @@ export interface Config { export interface FeatureFlags { indexing: boolean sandboxControls: boolean + backgroundSubagents: boolean } diff --git a/packages/opencode/src/kilocode/cli/lazy-commands.ts b/packages/opencode/src/kilocode/cli/lazy-commands.ts index ee49d9d5b6a..b917fab6d1a 100644 --- a/packages/opencode/src/kilocode/cli/lazy-commands.ts +++ b/packages/opencode/src/kilocode/cli/lazy-commands.ts @@ -19,6 +19,14 @@ export function hasLazyCommandSelection() { return selected } +export function resetLazyCommandSelection() { + selected = false +} + +export function markLazyCommandSelection() { + selected = true +} + export function lazy(input: { command: string | readonly string[] aliases?: string | readonly string[] @@ -29,9 +37,11 @@ export function lazy(input: { const load = () => (state.task ??= input.load()) if (completion) { tasks.push( - load().then((command) => { - state.command = command - }), + load() + .then((command) => { + state.command = command + }) + .catch(() => undefined), ) } return { @@ -39,7 +49,7 @@ export function lazy(input: { aliases: input.aliases, describe: input.describe, builder: ((args: Argv) => { - selected = true + markLazyCommandSelection() if (state.command) return build(state.command, args) return load().then((command) => build(command, args)) }) as never, diff --git a/packages/opencode/src/kilocode/help-command.ts b/packages/opencode/src/kilocode/help-command.ts index ecb5f90f14d..fa92607751a 100644 --- a/packages/opencode/src/kilocode/help-command.ts +++ b/packages/opencode/src/kilocode/help-command.ts @@ -1,13 +1,15 @@ import { cmd } from "../cli/cmd/cmd" import { generateHelp } from "./help" import type { Argv } from "yargs" +import { markLazyCommandSelection } from "@/kilocode/cli/lazy-commands" export function createHelpCommand(root?: () => Argv) { return cmd({ command: "help [command]", describe: "show full CLI reference", - builder: (yargs) => - yargs + builder: (yargs) => { + markLazyCommandSelection() + return yargs .positional("command", { describe: "command to show help for", type: "string", @@ -22,7 +24,8 @@ export function createHelpCommand(root?: () => Argv) { type: "string", choices: ["md", "text"] as const, default: "md" as const, - }), + }) + }, async handler(args) { if (!args.command && !args.all) { if (root) { diff --git a/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts b/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts index 10fa39f21d6..19633573202 100644 --- a/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts +++ b/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts @@ -25,6 +25,7 @@ import { Skill } from "@/skill" import { BackgroundJob } from "@/background/job" import { SessionRunState } from "@/session/run-state" import { SessionID } from "@/session/schema" +import { RuntimeFlags } from "@/effect/runtime-flags" import { AgentManagerRejectPayload, AgentManagerReplyPayload, @@ -48,6 +49,7 @@ export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode" const notebook = yield* Notebook.Service const background = yield* BackgroundJob.Service const runState = yield* SessionRunState.Service + const flags = yield* RuntimeFlags.Service const locations = yield* LocationServiceMap.Service // Location-scoped services, keyed by the request's directory and workspace. @@ -237,6 +239,7 @@ export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode" const backgroundJobPromote = Effect.fn("KilocodeHttpApi.backgroundJobPromote")(function* (ctx: { params: { jobID: string } }) { + if (!flags.experimentalBackgroundSubagents) return false const job = yield* background.get(ctx.params.jobID) if (!job) return yield* new HttpApiError.NotFound({}) const promoted = yield* background.promote(ctx.params.jobID) diff --git a/packages/opencode/src/kilocode/tool/agent-manager-models.ts b/packages/opencode/src/kilocode/tool/agent-manager-models.ts index 613489a1c89..d70d697db16 100644 --- a/packages/opencode/src/kilocode/tool/agent-manager-models.ts +++ b/packages/opencode/src/kilocode/tool/agent-manager-models.ts @@ -89,7 +89,7 @@ export const AgentManagerModelsTool = Tool.define< offset, total: matches.length, nextOffset, - hint: "Pass a model name (or one of its providers/IDs) as the agent_manager task `model`. Agent Manager picks the provider, preferring the one used by the current turn.", + hint: "Pass a model name (or one of its providers/IDs) as the agent_manager task `model`. Add the task `provider` to force one of the listed providers; otherwise Agent Manager prefers the provider used by the current turn.", }), metadata: { count: models.length, total: matches.length }, } diff --git a/packages/opencode/src/kilocode/tool/agent-manager-models.txt b/packages/opencode/src/kilocode/tool/agent-manager-models.txt index 8c73797bde6..8571d6045d9 100644 --- a/packages/opencode/src/kilocode/tool/agent-manager-models.txt +++ b/packages/opencode/src/kilocode/tool/agent-manager-models.txt @@ -1,5 +1,5 @@ Search the models available to Agent Manager sessions and inspect their reasoning variants. -Use this tool before `agent_manager` when you need to pick a model or reasoning effort. Results are grouped by model, not by provider, because you select a model and Agent Manager chooses the provider for you. With no arguments it returns the top available models (capped at 20); pass `query` to search by model name or ID, and `offset` to page further. The query is matched leniently: it is case-insensitive, ignores spacing and punctuation, and is order-independent, so `opus claude`, `glm5.2`, and `gpt5` all work. You do not need the exact model name. +Use this tool before `agent_manager` when you need to pick a model or reasoning effort. Results are grouped by model, not by provider, and list every provider that offers each model so you can constrain the provider when needed. With no arguments it returns the top available models (capped at 20); pass `query` to search by model name or ID, and `offset` to page further. The query is matched leniently: it is case-insensitive, ignores spacing and punctuation, and is order-independent, so `opus claude`, `glm5.2`, and `gpt5` all work. You do not need the exact model name. -Each result includes the model name, its reasoning variant names, and the providers that offer it (informational only). Pass the model name back as the `agent_manager` task `model`. Agent Manager resolves the provider automatically, preferring the provider used by the current turn and falling back to the Kilo Gateway, so you do not need to choose a provider yourself. +Each result includes the model name, its reasoning variant names, and the providers that offer it. Pass the model name back as the `agent_manager` task `model`; pass one of the listed provider IDs as the task `provider` when the provider must be explicit. When `provider` is omitted, Agent Manager resolves it automatically, preferring the one used by the current turn and falling back to the Kilo Gateway. diff --git a/packages/opencode/src/kilocode/tool/agent-manager.ts b/packages/opencode/src/kilocode/tool/agent-manager.ts index 8166f8302ee..630f08200f8 100644 --- a/packages/opencode/src/kilocode/tool/agent-manager.ts +++ b/packages/opencode/src/kilocode/tool/agent-manager.ts @@ -28,6 +28,10 @@ const Task = Schema.Struct({ description: "Optional model override from agent_manager_models (e.g. 'Claude Opus 4.1'). Omit unless the user requests a different model. Agent Manager otherwise inherits the current turn's model. A qualified provider/model ID is also accepted to force a specific provider.", }), + provider: Schema.optional(Schema.NullOr(Schema.String)).annotate({ + description: + "Optional provider ID to constrain model resolution (e.g. 'anthropic'). Use with model to select a model from a specific provider; omit to use the current-turn provider preference.", + }), variant: Schema.optional(Schema.NullOr(Schema.String)).annotate({ description: "Optional reasoning variant override from agent_manager_models. Specify it without model to override the inherited model's variant. Omit both to inherit the current turn's selection.", @@ -41,6 +45,9 @@ const Task = Schema.Struct({ Schema.makeFilter((task) => task.model?.trim() && !task.prompt?.trim() ? "A task model requires an initial prompt" : undefined, ), + Schema.makeFilter((task) => + task.provider?.trim() && !task.model?.trim() ? "A task provider requires a model" : undefined, + ), Schema.makeFilter((task) => task.variant?.trim() && !task.prompt?.trim() ? "A task variant requires an initial prompt" : undefined, ), @@ -245,6 +252,7 @@ function select( ...(task.branchName != null ? { branchName: task.branchName } : {}), } const value = task.model?.trim() + const provider = task.provider?.trim() const variant = task.variant?.trim() if (!value) { if (!variant) { @@ -271,12 +279,21 @@ function select( return { task: { ...base, model: source.model, variant } } } - const { pool, names } = lookup(all, value) + const scope = provider ? all.filter((item) => item.providerID === provider) : all + if (provider && scope.length === 0) { + return { + error: `Task ${index + 1} provider is not available for model selection: ${provider}. Requested model: ${value}.`, + } + } + + const { pool, names } = lookup(scope, value) if (pool.length === 0) { - const close = suggest(all, value) + const close = suggest(scope, value) const hint = close.length ? ` Closest matches: ${close.join(", ")}.` : "" return { - error: `Task ${index + 1} model is not available: ${value}.${hint} Use agent_manager_models to search models.`, + error: provider + ? `Task ${index + 1} model is not available from provider "${provider}": ${value}.${hint} Use agent_manager_models to search models.` + : `Task ${index + 1} model is not available: ${value}.${hint} Use agent_manager_models to search models.`, } } if (names.length > 1) { @@ -479,8 +496,9 @@ export const AgentManagerTool = Tool.define< ...(msg.model.variant ? { variant: msg.model.variant } : {}), } : undefined - const need = params.tasks.some((task) => task.model?.trim() || task.variant?.trim()) - const all = need ? candidates(yield* provider.list()) : [] + const need = params.tasks.some((task) => task.model?.trim() || task.provider?.trim() || task.variant?.trim()) + const providers = need ? yield* provider.list() : undefined + const all = providers ? candidates(providers) : [] const preferred = need ? (source?.model.providerID ?? (yield* provider.defaultModel().pipe( diff --git a/packages/opencode/src/kilocode/tool/agent-manager.txt b/packages/opencode/src/kilocode/tool/agent-manager.txt index 1d081b91dfb..4dbb410b840 100644 --- a/packages/opencode/src/kilocode/tool/agent-manager.txt +++ b/packages/opencode/src/kilocode/tool/agent-manager.txt @@ -14,7 +14,7 @@ Modes: - `worktree`: creates a new Agent Manager git worktree for each task, like the New Worktree dialog. - `local`: creates Agent Manager sessions in the current workspace directory without git worktree isolation. -Each task may provide a prompt, a short display name, a branch name, a `model`, and a model-specific reasoning `variant`. By default, omit `model` and `variant`: prompted tasks inherit the exact model and reasoning variant used by the current turn. Only specify `model` when the user explicitly asks to use or compare a different model, and only specify `variant` when the user explicitly asks for a different reasoning variant. A variant can be specified without a model to override the inherited model's variant. Never choose a different model merely because work is being fanned out. Specify an override `model` by name (e.g. "Claude Opus 4.1"); the name is matched leniently (case-insensitive, punctuation/spacing-insensitive, order-independent), so an approximate name like "opus 4.1" works and you do not need the exact name. Agent Manager picks the provider for you, preferring the provider used by the current turn and falling back to the Kilo Gateway. A qualified `provider/model` ID is also accepted to force a specific provider. If the name is ambiguous and matches several different models, the tool returns the candidates so you can choose. A model or variant selection requires an initial prompt so the session can persist that selection. Keep display names short because Agent Manager cards are narrow. Branch names are sanitized before worktree creation. Use `agent_manager_models` to search available models and variants on demand instead of guessing or loading the full model catalog. Prepared sessions without an initial prompt use the normal defaults. The agent and base branch settings always use the normal defaults. +Each task may provide a prompt, a short display name, a branch name, a `model`, an optional `provider`, and a model-specific reasoning `variant`. By default, omit `model`, `provider`, and `variant`: prompted tasks inherit the exact model and reasoning variant used by the current turn. Only specify `model` when the user explicitly asks to use or compare a different model, and only specify `variant` when the user explicitly asks for a different reasoning variant. A variant can be specified without a model to override the inherited model's variant. Specify `provider` with `model` to force a model-name match to one provider ID. Never choose a different model merely because work is being fanned out. Specify an override `model` by name (e.g. "Claude Opus 4.1"); the name is matched leniently (case-insensitive, punctuation/spacing-insensitive, order-independent), so an approximate name like "opus 4.1" works and you do not need the exact name. Agent Manager picks the provider for you, preferring the provider used by the current turn and falling back to the Kilo Gateway. A qualified `provider/model` ID is also accepted to force a specific provider. If the name is ambiguous and matches several different models, the tool returns the candidates so you can choose. A model or variant selection requires an initial prompt so the session can persist that selection. Keep display names short because Agent Manager cards are narrow. Branch names are sanitized before worktree creation. Use `agent_manager_models` to search available models and variants on demand instead of guessing or loading the full model catalog. Prepared sessions without an initial prompt use the normal defaults. The agent and base branch settings always use the normal defaults. By default, multiple tasks are started as independent Agent Manager sessions. Set `versions` to true only when all tasks are alternate versions of the same work that should be compared together. Versioned worktrees are grouped in Agent Manager and branch names may receive version suffixes. diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts index 259a1fedc93..28b545944b4 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts @@ -1,4 +1,5 @@ import * as InstanceState from "@/effect/instance-state" +import { WorkspaceRef } from "@/effect/instance-ref" // kilocode_change - preserve the shared location key shape import { FileSystem } from "@opencode-ai/core/filesystem" import { LocationServiceMap } from "@opencode-ai/core/location-services" // kilocode_change - reuse the server location map import { Ripgrep } from "@opencode-ai/core/ripgrep" @@ -17,11 +18,19 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl const locations = yield* LocationServiceMap.Service const filesystem = Effect.fnUntraced(function* (effect: Effect.Effect) { + // kilocode_change start - preserve the shared location key shape + const workspaceID = yield* WorkspaceRef return yield* effect.pipe( Effect.provide( - locations.get(Location.Ref.make({ directory: AbsolutePath.make((yield* InstanceState.context).directory) })), + locations.get( + Location.Ref.make({ + directory: AbsolutePath.make((yield* InstanceState.context).directory), + workspaceID, + }), + ), ), ) + // kilocode_change end }) const findText = Effect.fn("FileHttpApi.findText")(function* (ctx: { query: { pattern: string } }) { diff --git a/packages/opencode/test/kilocode/agent-manager-tool.test.ts b/packages/opencode/test/kilocode/agent-manager-tool.test.ts index 69b33d42384..411d2d173f3 100644 --- a/packages/opencode/test/kilocode/agent-manager-tool.test.ts +++ b/packages/opencode/test/kilocode/agent-manager-tool.test.ts @@ -229,6 +229,14 @@ describe("agent_manager tool", () => { expect(Schema.is(Params)({ action: "stop", sessionID: "invalid" })).toBe(false) }) + test("validates provider selectors at the task level", () => { + expect(Schema.is(Params)({ mode: "local", tasks: [{ prompt: "Fix", model: "Shared", provider: "kilo" }] })).toBe( + true, + ) + expect(Schema.is(Params)({ mode: "local", tasks: [{ prompt: "Fix", provider: "kilo" }] })).toBe(false) + expect(Schema.is(Params)({ mode: "local", tasks: [{ prompt: "Fix", model: "Shared", provider: 42 }] })).toBe(false) + }) + // Regression for #13029: the OpenAI Responses API forces a value for every // advertised property. With action nullable the model can decline it and the // start request survives; with a populated action the action wins instead. @@ -673,6 +681,12 @@ describe("agent_manager tool", () => { expect(task?.variant).toBe("low") }) + test("uses an explicitly selected provider for a shared model name", async () => { + const task = await publish(runtime, { prompt: "Fix", model: " Shared ", provider: " kilo " }) + expect(String(task?.model?.providerID)).toBe("kilo") + expect(String(task?.model?.modelID)).toBe("kilo/shared") + }) + test("uses the provider of a different default model when that is the user's choice", async () => { const rt = makeRuntime("kilo") const task = await publish(rt, { prompt: "Fix", model: "Shared", variant: "low" }) @@ -717,6 +731,43 @@ describe("agent_manager tool", () => { expect(result.metadata.count).toBe(0) }) + test("reports a model unavailable from an explicit provider", async () => { + const tool = await init() + const calls: unknown[] = [] + + const result = await runtime.runPromise( + provideTmpdirInstance(() => + tool.execute( + { mode: "local", tasks: [{ prompt: "Fix", model: "Reasoning Model", provider: "kilo" }] }, + { ...ctx, ask: (input: unknown) => Effect.sync(() => calls.push(input)) }, + ), + ).pipe(Effect.scoped), + ) + + expect(calls).toEqual([]) + expect(result.output).toContain('model is not available from provider "kilo": Reasoning Model') + expect(result.metadata.count).toBe(0) + }) + + test("rejects an unknown provider without touching inherited object properties", async () => { + const tool = await init() + const calls: unknown[] = [] + + const result = await runtime.runPromise( + provideTmpdirInstance(() => + tool.execute( + { mode: "local", tasks: [{ prompt: "Fix", model: "Shared", provider: "__proto__" }] }, + { ...ctx, ask: (input: unknown) => Effect.sync(() => calls.push(input)) }, + ), + ).pipe(Effect.scoped), + ) + + expect(calls).toEqual([]) + expect(result.output).toContain("provider is not available for model selection: __proto__") + expect(result.output).toContain("Requested model: Shared") + expect(result.metadata.count).toBe(0) + }) + test("echoes how each named model resolved", async () => { const tool = await init() const result = await runtime.runPromise( diff --git a/packages/opencode/test/kilocode/cli/bootstrap-runtime.test.ts b/packages/opencode/test/kilocode/cli/bootstrap-runtime.test.ts index 4ac32559b59..7f580e3b6fe 100644 --- a/packages/opencode/test/kilocode/cli/bootstrap-runtime.test.ts +++ b/packages/opencode/test/kilocode/cli/bootstrap-runtime.test.ts @@ -1,7 +1,13 @@ -import { describe, expect, test } from "bun:test" +import { afterEach, beforeEach, describe, expect, test } from "bun:test" import { KiloCli } from "../../../src/kilocode/cli/setup" +import { createHelpCommand } from "../../../src/kilocode/help-command" +import { resetLazyCommandSelection } from "../../../src/kilocode/cli/lazy-commands" +import yargs from "yargs" describe("CLI bootstrap runtime selection", () => { + beforeEach(resetLazyCommandSelection) + afterEach(resetLazyCommandSelection) + test("uses the narrow runtime for worker-backed TUI launches", () => { expect(KiloCli.workerTui({ _: [] })).toBe(true) expect(KiloCli.workerTui({ _: ["./project"] })).toBe(true) @@ -11,4 +17,11 @@ describe("CLI bootstrap runtime selection", () => { expect(KiloCli.workerTui({ _: [], mini: true })).toBe(false) expect(KiloCli.workerTui({ _: [], worktree: "feature" })).toBe(false) }) + + test("keeps full bootstrap when the eager help command is selected", () => { + const command = createHelpCommand() + if (typeof command.builder !== "function") throw new Error("help builder is not a function") + command.builder(yargs([])) + expect(KiloCli.workerTui({ _: [] })).toBe(false) + }) }) diff --git a/packages/opencode/test/kilocode/shared-location-map-key.test.ts b/packages/opencode/test/kilocode/shared-location-map-key.test.ts new file mode 100644 index 00000000000..3fa931e7798 --- /dev/null +++ b/packages/opencode/test/kilocode/shared-location-map-key.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test" +import { Equal, Hash } from "effect" +import { readFileSync } from "node:fs" +import { Location } from "@opencode-ai/core/location" +import { AbsolutePath } from "@opencode-ai/core/schema" + +const opencode = new URL("../../src/", import.meta.url) +const server = new URL("../../../server/src/", import.meta.url) + +function source(root: URL, path: string) { + return readFileSync(new URL(path, root), "utf8") +} + +describe("shared location service map keys", () => { + test("all location route consumers include workspaceID in their cache key", () => { + const consumers = [ + [opencode, "server/routes/instance/httpapi/handlers/file.ts"], + [opencode, "server/routes/instance/httpapi/handlers/pty.ts"], + [server, "middleware/session-location.ts"], + ] as const + + for (const [root, path] of consumers) { + expect(source(root, path), path).toMatch(/Location\.Ref\.make\(\{[^}]*workspaceID/) + } + }) + + test("omitted and explicit undefined workspace IDs are distinct keys", () => { + const directory = AbsolutePath.make("/workspace") + const omitted = Location.Ref.make({ directory }) + const explicit = Location.Ref.make({ directory, workspaceID: undefined }) + + expect(Object.hasOwn(omitted, "workspaceID")).toBe(false) + expect(Object.hasOwn(explicit, "workspaceID")).toBe(true) + expect(Equal.equals(omitted, explicit)).toBe(false) + expect(Hash.hash(omitted)).not.toBe(Hash.hash(explicit)) + }) +}) diff --git a/packages/opencode/test/server/session-actions.test.ts b/packages/opencode/test/server/session-actions.test.ts index ee318274b3d..306c3cf5c3b 100644 --- a/packages/opencode/test/server/session-actions.test.ts +++ b/packages/opencode/test/server/session-actions.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, mock } from "bun:test" import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { Effect, Fiber, Layer } from "effect" // kilocode_change +import { ConfigProvider, Effect, Fiber, Layer } from "effect" // kilocode_change import { BackgroundJob } from "@/background/job" // kilocode_change import { Session as SessionNs } from "@/session/session" import { disposeAllInstances, TestInstance } from "../fixture/fixture" @@ -9,7 +9,23 @@ import { httpApiLayer, requestInDirectory } from "./httpapi-layer" // kilocode_change start - provide the background-job service for promotion coverage const it = testEffect( - Layer.mergeAll(LayerNode.compile(SessionNs.node), LayerNode.compile(BackgroundJob.node), httpApiLayer), // kilocode_change + Layer.mergeAll( + LayerNode.compile(SessionNs.node), + LayerNode.compile(BackgroundJob.node), + httpApiLayer, + ), // kilocode_change +) +const disabled = testEffect( + Layer.mergeAll(LayerNode.compile(SessionNs.node), LayerNode.compile(BackgroundJob.node), httpApiLayer).pipe( + Layer.provide( + ConfigProvider.layer( + ConfigProvider.fromUnknown({ + KILO_EXPERIMENTAL_BACKGROUND_SUBAGENTS: "false", + KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: "true", + }), + ), + ), + ), ) // kilocode_change end @@ -127,6 +143,26 @@ describe("session action routes", () => { { git: true }, ) + disabled.instance( + "background job promotion is disabled when the flag is off", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const jobs = yield* BackgroundJob.Service + const job = yield* jobs.start({ type: "task", metadata: { parentSessionId: "ses_parent" }, run: Effect.never }) + + const res = yield* requestInDirectory(`/kilocode/background-jobs/${job.id}/promote`, test.directory, { + method: "POST", + }) + + expect(res.status).toBe(200) + expect(yield* res.json).toBe(false) + expect((yield* jobs.get(job.id))?.metadata?.background).toBeUndefined() + yield* jobs.cancel(job.id) + }), + { git: true }, + ) + // kilocode_change start - verify HTTP promotion of a running task it.instance( "experimental background route backgrounds a synchronous subagent",