Merge remote-tracking branch 'origin/main' into bold-island
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Fix project-scoped Agent Manager history activation and session placement.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Prevent overlapping Agent Manager history activations from leaving stale project-switch state.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Load Agent Manager worktree diffs faster and keep warmed reviews visible when switching worktrees.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Fix CLI help disposal and shell completion after startup optimization.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Prevent duplicate-event tracking from suppressing delayed sync events after reconnects or high event bursts.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Prevent duplicate reasoning and response text while subagent sessions are open.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Stop manually aborted turns without briefly showing an interruption warning.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Preserve the Agent Manager sidebar scroll position when worktrees are deleted.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Keep file route location services on the same cache key as workspace-aware server routes.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Use a browser-valid close code when Agent Manager terminal replay exceeds its buffer limit
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 `<base>/.kilo/worktrees/<slug>` 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<KiloBackendAppService>().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<String>): 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 <T, R> parallel(items: List<T>, block: suspend (T) -> R): List<R> = 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<WorktreeDto> {
|
||||
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<WorktreeDto> {
|
||||
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<WorktreeDto>): List<WorktreeDto> {
|
||||
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<String>): WorktreeState {
|
||||
return next
|
||||
}
|
||||
|
||||
private fun appendWorktreeOrder(file: Path, path: String, paths: List<String>) {
|
||||
private fun prependWorktreeOrder(file: Path, path: String, paths: List<String>) {
|
||||
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) {
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ sealed class KiloWorkspaceState {
|
||||
val skills: List<SkillInfo>,
|
||||
) : KiloWorkspaceState()
|
||||
data class Unsupported(val reason: String) : KiloWorkspaceState()
|
||||
data class Missing(val path: String) : KiloWorkspaceState()
|
||||
data class Error(val message: String, val errors: List<LoadError> = emptyList()) : KiloWorkspaceState()
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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<KiloBackendAppService>()
|
||||
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<IllegalStateException> {
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<KiloSessionService>().activity.map(::sessionAttentionNeeded).collect { needed ->
|
||||
withContext(Dispatchers.Main) {
|
||||
|
||||
@@ -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<String, SessionActivityDto>): Boolean =
|
||||
activity.values.any {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<String, SessionActivityDto>,
|
||||
): Map<String, SessionActivityKind> = 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
|
||||
}
|
||||
|
||||
@@ -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<PendingWorktreePrompt>().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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<List<SessionDto>>(emptyList())
|
||||
val sessions: StateFlow<List<SessionDto>> = _sessions.asStateFlow()
|
||||
|
||||
/** Live session status map from SSE events. */
|
||||
val statuses: StateFlow<Map<String, SessionStatusDto>> =
|
||||
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<Set<String>>(emptySet())
|
||||
|
||||
/** Live session activity map from backend global events. */
|
||||
/** Live session status map from SSE events, minus sessions deleted this run. */
|
||||
val statuses: StateFlow<Map<String, SessionStatusDto>> =
|
||||
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<Map<String, SessionActivityDto>> =
|
||||
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<String, SessionActivityKind> =
|
||||
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<String, SessionActivityKind> {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<JComponent, (JPanel, JComponent) -> Rectangle>()
|
||||
|
||||
private val blocking = linkedSetOf<JComponent>()
|
||||
|
||||
/** 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) ->
|
||||
|
||||
@@ -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<ActiveListBadge> 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1.5" y="1.5" width="13" height="10" rx="1.5" stroke="#818594" stroke-width="1" stroke-linejoin="round"/>
|
||||
<path d="M7 12H9V14H12V15H4V14H7V12Z" fill="#818594"/>
|
||||
<rect x="1.5" y="1.5" width="13" height="10" rx="1.5" stroke="#A8ADBD" stroke-width="1" stroke-linejoin="round"/>
|
||||
<path d="M7 12H9V14H12V15H4V14H7V12Z" fill="#A8ADBD"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 276 B After Width: | Height: | Size: 276 B |
@@ -1,4 +1,4 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1.5" y="1.5" width="13" height="10" rx="1.5" stroke="#6F737A" stroke-width="1" stroke-linejoin="round"/>
|
||||
<path d="M7 12H9V14H12V15H4V14H7V12Z" fill="#6F737A"/>
|
||||
<rect x="1.5" y="1.5" width="13" height="10" rx="1.5" stroke="#9DA0A8" stroke-width="1" stroke-linejoin="round"/>
|
||||
<path d="M7 12H9V14H12V15H4V14H7V12Z" fill="#9DA0A8"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 276 B After Width: | Height: | Size: 276 B |
@@ -1 +1 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="#818594"><path d="M14 5.5C14 4.121 12.879 3 11.5 3C10.121 3 9 4.121 9 5.5C9 6.682 9.826 7.669 10.93 7.928C10.744 8.546 10.177 9 9.5 9H6.5C5.935 9 5.419 9.195 5 9.512V4.949C6.14 4.717 7 3.707 7 2.5C7 1.121 5.879 0 4.5 0C3.121 0 2 1.121 2 2.5C2 3.708 2.86 4.717 4 4.949V11.05C2.86 11.282 2 12.292 2 13.499C2 14.878 3.121 15.999 4.5 15.999C5.879 15.999 7 14.878 7 13.499C7 12.317 6.174 11.33 5.07 11.071C5.256 10.453 5.823 9.999 6.5 9.999H9.5C10.723 9.999 11.74 9.115 11.954 7.953C13.116 7.738 14 6.723 14 5.5ZM3 2.5C3 1.673 3.673 1 4.5 1C5.327 1 6 1.673 6 2.5C6 3.327 5.327 4 4.5 4C3.673 4 3 3.327 3 2.5ZM6 13.5C6 14.327 5.327 15 4.5 15C3.673 15 3 14.327 3 13.5C3 12.673 3.673 12 4.5 12C5.327 12 6 12.673 6 13.5ZM11.5 7C10.673 7 10 6.327 10 5.5C10 4.673 10.673 4 11.5 4C12.327 4 13 4.673 13 5.5C13 6.327 12.327 7 11.5 7Z"/></svg>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="#A8ADBD"><path d="M14 5.5C14 4.121 12.879 3 11.5 3C10.121 3 9 4.121 9 5.5C9 6.682 9.826 7.669 10.93 7.928C10.744 8.546 10.177 9 9.5 9H6.5C5.935 9 5.419 9.195 5 9.512V4.949C6.14 4.717 7 3.707 7 2.5C7 1.121 5.879 0 4.5 0C3.121 0 2 1.121 2 2.5C2 3.708 2.86 4.717 4 4.949V11.05C2.86 11.282 2 12.292 2 13.499C2 14.878 3.121 15.999 4.5 15.999C5.879 15.999 7 14.878 7 13.499C7 12.317 6.174 11.33 5.07 11.071C5.256 10.453 5.823 9.999 6.5 9.999H9.5C10.723 9.999 11.74 9.115 11.954 7.953C13.116 7.738 14 6.723 14 5.5ZM3 2.5C3 1.673 3.673 1 4.5 1C5.327 1 6 1.673 6 2.5C6 3.327 5.327 4 4.5 4C3.673 4 3 3.327 3 2.5ZM6 13.5C6 14.327 5.327 15 4.5 15C3.673 15 3 14.327 3 13.5C3 12.673 3.673 12 4.5 12C5.327 12 6 12.673 6 13.5ZM11.5 7C10.673 7 10 6.327 10 5.5C10 4.673 10.673 4 11.5 4C12.327 4 13 4.673 13 5.5C13 6.327 12.327 7 11.5 7Z"/></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 917 B After Width: | Height: | Size: 917 B |
@@ -1 +1 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="#6F737A"><path d="M14 5.5C14 4.121 12.879 3 11.5 3C10.121 3 9 4.121 9 5.5C9 6.682 9.826 7.669 10.93 7.928C10.744 8.546 10.177 9 9.5 9H6.5C5.935 9 5.419 9.195 5 9.512V4.949C6.14 4.717 7 3.707 7 2.5C7 1.121 5.879 0 4.5 0C3.121 0 2 1.121 2 2.5C2 3.708 2.86 4.717 4 4.949V11.05C2.86 11.282 2 12.292 2 13.499C2 14.878 3.121 15.999 4.5 15.999C5.879 15.999 7 14.878 7 13.499C7 12.317 6.174 11.33 5.07 11.071C5.256 10.453 5.823 9.999 6.5 9.999H9.5C10.723 9.999 11.74 9.115 11.954 7.953C13.116 7.738 14 6.723 14 5.5ZM3 2.5C3 1.673 3.673 1 4.5 1C5.327 1 6 1.673 6 2.5C6 3.327 5.327 4 4.5 4C3.673 4 3 3.327 3 2.5ZM6 13.5C6 14.327 5.327 15 4.5 15C3.673 15 3 14.327 3 13.5C3 12.673 3.673 12 4.5 12C5.327 12 6 12.673 6 13.5ZM11.5 7C10.673 7 10 6.327 10 5.5C10 4.673 10.673 4 11.5 4C12.327 4 13 4.673 13 5.5C13 6.327 12.327 7 11.5 7Z"/></svg>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="#9DA0A8"><path d="M14 5.5C14 4.121 12.879 3 11.5 3C10.121 3 9 4.121 9 5.5C9 6.682 9.826 7.669 10.93 7.928C10.744 8.546 10.177 9 9.5 9H6.5C5.935 9 5.419 9.195 5 9.512V4.949C6.14 4.717 7 3.707 7 2.5C7 1.121 5.879 0 4.5 0C3.121 0 2 1.121 2 2.5C2 3.708 2.86 4.717 4 4.949V11.05C2.86 11.282 2 12.292 2 13.499C2 14.878 3.121 15.999 4.5 15.999C5.879 15.999 7 14.878 7 13.499C7 12.317 6.174 11.33 5.07 11.071C5.256 10.453 5.823 9.999 6.5 9.999H9.5C10.723 9.999 11.74 9.115 11.954 7.953C13.116 7.738 14 6.723 14 5.5ZM3 2.5C3 1.673 3.673 1 4.5 1C5.327 1 6 1.673 6 2.5C6 3.327 5.327 4 4.5 4C3.673 4 3 3.327 3 2.5ZM6 13.5C6 14.327 5.327 15 4.5 15C3.673 15 3 14.327 3 13.5C3 12.673 3.673 12 4.5 12C5.327 12 6 12.673 6 13.5ZM11.5 7C10.673 7 10 6.327 10 5.5C10 4.673 10.673 4 11.5 4C12.327 4 13 4.673 13 5.5C13 6.327 12.327 7 11.5 7Z"/></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 917 B After Width: | Height: | Size: 917 B |
@@ -1 +1 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="#818594"><path d="M8 9C8.55228 9 9 9.44771 9 10C9 10.5523 8.55228 11 8 11C7.44772 11 7 10.5523 7 10C7 9.44771 7.44772 9 8 9Z"/><path fill-rule="evenodd" clip-rule="evenodd" d="M8 1C9.654 1 11 2.346 11 4V6H12C13.103 6 14 6.897 14 8V13C14 14.103 13.103 15 12 15H4C2.897 15 2 14.103 2 13V8C2 6.897 2.897 6 4 6H5V4C5 2.346 6.346 1 8 1ZM4 7C3.449 7 3 7.449 3 8V13C3 13.551 3.449 14 4 14H12C12.551 14 13 13.551 13 13V8C13 7.449 12.551 7 12 7H4ZM8 2C6.897 2 6 2.897 6 4V6H10V4C10 2.897 9.103 2 8 2Z"/></svg>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="#A8ADBD"><path d="M8 9C8.55228 9 9 9.44771 9 10C9 10.5523 8.55228 11 8 11C7.44772 11 7 10.5523 7 10C7 9.44771 7.44772 9 8 9Z"/><path fill-rule="evenodd" clip-rule="evenodd" d="M8 1C9.654 1 11 2.346 11 4V6H12C13.103 6 14 6.897 14 8V13C14 14.103 13.103 15 12 15H4C2.897 15 2 14.103 2 13V8C2 6.897 2.897 6 4 6H5V4C5 2.346 6.346 1 8 1ZM4 7C3.449 7 3 7.449 3 8V13C3 13.551 3.449 14 4 14H12C12.551 14 13 13.551 13 13V8C13 7.449 12.551 7 12 7H4ZM8 2C6.897 2 6 2.897 6 4V6H10V4C10 2.897 9.103 2 8 2Z"/></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 590 B After Width: | Height: | Size: 590 B |
@@ -1 +1 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="#6F737A"><path d="M8 9C8.55228 9 9 9.44771 9 10C9 10.5523 8.55228 11 8 11C7.44772 11 7 10.5523 7 10C7 9.44771 7.44772 9 8 9Z"/><path fill-rule="evenodd" clip-rule="evenodd" d="M8 1C9.654 1 11 2.346 11 4V6H12C13.103 6 14 6.897 14 8V13C14 14.103 13.103 15 12 15H4C2.897 15 2 14.103 2 13V8C2 6.897 2.897 6 4 6H5V4C5 2.346 6.346 1 8 1ZM4 7C3.449 7 3 7.449 3 8V13C3 13.551 3.449 14 4 14H12C12.551 14 13 13.551 13 13V8C13 7.449 12.551 7 12 7H4ZM8 2C6.897 2 6 2.897 6 4V6H10V4C10 2.897 9.103 2 8 2Z"/></svg>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="#9DA0A8"><path d="M8 9C8.55228 9 9 9.44771 9 10C9 10.5523 8.55228 11 8 11C7.44772 11 7 10.5523 7 10C7 9.44771 7.44772 9 8 9Z"/><path fill-rule="evenodd" clip-rule="evenodd" d="M8 1C9.654 1 11 2.346 11 4V6H12C13.103 6 14 6.897 14 8V13C14 14.103 13.103 15 12 15H4C2.897 15 2 14.103 2 13V8C2 6.897 2.897 6 4 6H5V4C5 2.346 6.346 1 8 1ZM4 7C3.449 7 3 7.449 3 8V13C3 13.551 3.449 14 4 14H12C12.551 14 13 13.551 13 13V8C13 7.449 12.551 7 12 7H4ZM8 2C6.897 2 6 2.897 6 4V6H10V4C10 2.897 9.103 2 8 2Z"/></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 590 B After Width: | Height: | Size: 590 B |
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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<Any?> }
|
||||
val title = edt {
|
||||
val row = list.model.getElementAt(0)
|
||||
val comp = list.cellRenderer.getListCellRendererComponent(list, row, 0, false, false)
|
||||
components(comp).filterIsInstance<SimpleColoredComponent>().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<ActiveListBadge>(), 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<Component> {
|
||||
val out = mutableListOf<Component>()
|
||||
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()
|
||||
|
||||
@@ -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<Unit>()
|
||||
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<RemoveWorktreeResultDto>()
|
||||
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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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`() {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<ActiveListItem>
|
||||
val row = list.model.getElementAt(0) as ActiveListItem
|
||||
val comp = list.cellRenderer.getListCellRendererComponent(list, row, 0, true, true)
|
||||
val title = components(comp).filterIsInstance<SimpleColoredComponent>().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())
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<Unit>()
|
||||
rpc.historyGate = gate
|
||||
ui = newUi(id = "ses_test")
|
||||
|
||||
ApplicationManager.getApplication().invokeAndWait {
|
||||
controller().prompt("hello")
|
||||
}
|
||||
gate.complete(Unit)
|
||||
settle()
|
||||
|
||||
val overlay = find<SessionAccountOverlay>(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")
|
||||
|
||||
@@ -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<SessionControllerEvent.ConnectionChanged.ShowError>().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()
|
||||
|
||||
@@ -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<Unit>()
|
||||
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")
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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<ActiveListItem>(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<SimpleColoredComponent>().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<ActiveListItem>(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<GroupHeaderSeparator>().single().font.isBold)
|
||||
assertFalse(components(plain).filterIsInstance<GroupHeaderSeparator>().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<ActiveListItem>(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<GroupHeaderSeparator>().single().isHideLine)
|
||||
divider.getListCellRendererComponent(list, second, 1, false, false)
|
||||
assertFalse(components(divider).filterIsInstance<GroupHeaderSeparator>().single().isHideLine)
|
||||
|
||||
none.getListCellRendererComponent(list, first, 0, false, false)
|
||||
assertTrue(components(none).filterIsInstance<GroupHeaderSeparator>().single().isHideLine)
|
||||
none.getListCellRendererComponent(list, second, 1, false, false)
|
||||
assertTrue(components(none).filterIsInstance<GroupHeaderSeparator>().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<ActiveListItem>(listOf(row))
|
||||
val list = JBList(model)
|
||||
val renderer = ActiveListRenderer(model, ActiveListConfig.Equal)
|
||||
|
||||
renderer.getListCellRendererComponent(list, row, 0, false, false)
|
||||
val mark = components(renderer).filterIsInstance<JBLabel>().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<ActiveListItem>(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<JBLabel>().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<String>()
|
||||
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
|
||||
|
||||
@@ -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<ai.kilocode.rpc.dto.ProviderActionResultDto>()
|
||||
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<JBLabel>().single { it.name == "kilo.provider.oauth.qr" }
|
||||
assertNotNull(qr.icon)
|
||||
}
|
||||
|
||||
@@ -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<String>()
|
||||
val files = mutableListOf<Path>()
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -8,6 +8,7 @@ enum class KiloWorkspaceStatusDto {
|
||||
LOADING,
|
||||
READY,
|
||||
UNSUPPORTED,
|
||||
MISSING,
|
||||
ERROR,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<void> {
|
||||
@@ -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" })
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<ExecResult> {
|
||||
execGit(args: string[], cwd: string, options?: { stdin?: string; signal?: AbortSignal }): Promise<ExecResult> {
|
||||
return this.exec(args, cwd, options)
|
||||
}
|
||||
|
||||
execGitBuffer(args: string[], cwd: string): Promise<ExecBufferResult> {
|
||||
return this.execBuffer(args, cwd)
|
||||
execGitBuffer(args: string[], cwd: string, options?: { signal?: AbortSignal }): Promise<ExecBufferResult> {
|
||||
return this.execBuffer(args, cwd, options)
|
||||
}
|
||||
|
||||
private async exec(args: string[], cwd: string, options?: ExecOptions): Promise<ExecResult> {
|
||||
@@ -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<string> {
|
||||
@@ -642,7 +643,7 @@ export class GitOps {
|
||||
}
|
||||
|
||||
private invoke(cmd: string, args: string[], cwd: string, options?: ExecOptions): Promise<ExecBufferResult> {
|
||||
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),
|
||||
|
||||
@@ -8,7 +8,10 @@ import { recordForkHandoff } from "./fork-handoff"
|
||||
|
||||
export interface ContinueContext {
|
||||
root: string
|
||||
getClient: () => KiloClient
|
||||
connection: {
|
||||
getClient: () => KiloClient
|
||||
runExplicitAbort: <T>(sessionId: string, directory: string, action: () => Promise<T>) => Promise<T>
|
||||
}
|
||||
createWorktreeOnDisk: (opts: { baseBranch: string; baseRef: string }) => Promise<{
|
||||
worktree: { id: string }
|
||||
result: CreateWorktreeResult
|
||||
@@ -30,10 +33,13 @@ export type StepResult<T> = { 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<void> {
|
||||
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<StepResult<Session>> {
|
||||
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" }
|
||||
|
||||
@@ -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<string, { additions: number; deletions: number; binary: boolean }>()
|
||||
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<string, { additions: number; deletions: number; binary: boolean }>()
|
||||
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<string> {
|
||||
@@ -144,7 +147,22 @@ async function statStamp(dir: string, file: string): Promise<string> {
|
||||
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<number> {
|
||||
@@ -166,28 +184,28 @@ function statusFromCode(code: string): Status {
|
||||
}
|
||||
|
||||
async function list(git: GitOps, dir: string, anc: string, log?: Log): Promise<Meta[]> {
|
||||
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<string>()
|
||||
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<M
|
||||
})
|
||||
}
|
||||
|
||||
const untracked = await git.execGit(["ls-files", "--others", "--exclude-standard"], dir)
|
||||
if (untracked.code !== 0) {
|
||||
log?.("git ls-files --others failed", { code: untracked.code, stderr: untracked.stderr.trim() })
|
||||
return result
|
||||
@@ -265,28 +282,83 @@ export async function diffSummary(git: GitOps, dir: string, base: string, log?:
|
||||
|
||||
export function createLocalDiff(git: GitOps, log?: Log) {
|
||||
const states = new Map<string, { anc: string; metas: Map<string, Meta> }>()
|
||||
const generations = new Map<string, number>()
|
||||
const details = new Map<string, { value: WorktreeDiffEntry; bytes: number; stamp: string }>()
|
||||
const pending = new Map<string, { signal?: AbortSignal; work: Promise<WorktreeDiffEntry> }>()
|
||||
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<WorktreeDiffEntry[]> => {
|
||||
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<WorktreeDiffEntry | null> => {
|
||||
file: async (dir: string, base: string, file: string, signal?: AbortSignal): Promise<WorktreeDiffEntry | null> => {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
return stat?.size ?? 0
|
||||
}
|
||||
|
||||
async function readBlob(git: GitOps, dir: string, ref: string, file: string): Promise<Buffer | undefined> {
|
||||
const result = await git.execGitBuffer(["show", `${ref}:${file}`], dir)
|
||||
async function readBlob(
|
||||
git: GitOps,
|
||||
dir: string,
|
||||
ref: string,
|
||||
file: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Buffer | undefined> {
|
||||
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<Buffer | undefined>
|
||||
return readImageFile(full)
|
||||
}
|
||||
|
||||
async function readBefore(git: GitOps, dir: string, anc: string, file: string, status: Status): Promise<string> {
|
||||
async function readBefore(
|
||||
git: GitOps,
|
||||
dir: string,
|
||||
anc: string,
|
||||
file: string,
|
||||
status: Status,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
async function unifiedPatch(
|
||||
git: GitOps,
|
||||
dir: string,
|
||||
anc: string,
|
||||
file: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> {
|
||||
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<WorktreeDiffEntry> {
|
||||
async function materialize(
|
||||
git: GitOps,
|
||||
dir: string,
|
||||
anc: string,
|
||||
meta: Meta,
|
||||
log?: Log,
|
||||
signal?: AbortSignal,
|
||||
): Promise<WorktreeDiffEntry> {
|
||||
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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<T>(fn: () => Promise<T>): Promise<T> {
|
||||
await this.acquire()
|
||||
async run<T>(fn: () => Promise<T>, signal?: AbortSignal): Promise<T> {
|
||||
await this.acquire(signal)
|
||||
try {
|
||||
return await fn()
|
||||
} finally {
|
||||
@@ -20,22 +20,32 @@ export class Semaphore {
|
||||
}
|
||||
}
|
||||
|
||||
private acquire(): Promise<void> {
|
||||
private acquire(signal?: AbortSignal): Promise<void> {
|
||||
if (signal?.aborted) return Promise.reject(signal.reason)
|
||||
if (this.running < this.limit) {
|
||||
this.running++
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise<void>((resolve) => {
|
||||
this.pending.push(() => {
|
||||
this.running++
|
||||
resolve()
|
||||
})
|
||||
return new Promise<void>((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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ export class SourceController {
|
||||
private interval: ReturnType<typeof setInterval> | undefined
|
||||
private lastHash: string | undefined
|
||||
private epoch = 0
|
||||
private readonly fetches = new Map<DiffSource, Promise<boolean>>()
|
||||
|
||||
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<void>((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<boolean> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<WorktreeDiffEntry[]>
|
||||
file: (dir: string, base: string, file: string, signal?: AbortSignal) => Promise<WorktreeDiffEntry | null>
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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<WorktreeDiffEntry[]>
|
||||
file?: (dir: string, base: string, file: string, signal?: AbortSignal) => Promise<WorktreeDiffEntry | null>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { hasIndexingPlugin } from "@kilocode/kilo-indexing/detect"
|
||||
import type { KiloClient } from "@kilocode/sdk/v2"
|
||||
|
||||
type PluginSpec = string | [string, Record<string, unknown>]
|
||||
|
||||
@@ -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<KiloClient, "experimental">, 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<KiloClient, "config" | "global">
|
||||
type Client = Pick<KiloClient, "config" | "global" | "experimental">
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<SSEEventListener> = new Set()
|
||||
private readonly duplicateEvent = createDuplicateEventFilter()
|
||||
private readonly filteredListeners = new Set<{ filter: SSEEventFilter; listener: SSEEventListener }>()
|
||||
private readonly explicitAborts = new ExplicitAbortState()
|
||||
private readonly stateListeners: Set<StateListener> = new Set()
|
||||
private readonly notificationDismissListeners: Set<NotificationDismissListener> = new Set()
|
||||
private readonly languageChangeListeners: Set<LanguageChangeListener> = 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<T>(sessionID: string, directory: string, action: () => Promise<T>): Promise<T> {
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<number>; stopped: boolean; buffered: Buffered[]; generation: number; idle: boolean }
|
||||
|
||||
export class ExplicitAbortState {
|
||||
private readonly active = new Set<string>()
|
||||
private readonly states = new Map<string, State>()
|
||||
private readonly generations = new Map<string, number>()
|
||||
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<SSEPayload, { type: "session.status" }>, 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)}`
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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<number, FrameRequestCallback>()
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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"')
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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) })
|
||||
|
||||