feat(jetbrains): show worktree change and PR badges

This commit is contained in:
kirillk
2026-08-05 20:25:12 -04:00
parent 46eac53716
commit 4100415aca
29 changed files with 2332 additions and 10 deletions
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": minor
---
Show worktree change counts, ahead/behind counts, and pull request badges in JetBrains Agent Manager worktree rows and editor headers.
@@ -0,0 +1,100 @@
# Worktree session "deleting…" state + delete-failure notification + session logging
## Goal
In the worktree editor session list, give a session that is being deleted an explicit optimistic "deleting…" state, exclude it from further deletion, notify on delete failure, and add create/delete logging on both frontend and backend.
Scope is the **worktree editor session list only** (`WorktreeSessionEditorPanel` / `WorktreeSessionEditorManager`). The sidebar `HistoryPanel` (different renderer) is out of scope.
## Resolved decisions (from interview)
1. **Toolbar Delete on in-progress rows**: the Delete action excludes sessions already being deleted. Toolbar button is enabled only when the selection contains ≥1 still-deletable session; disabled if only "deleting…" (or the New) rows are selected. Mixed selections act only on the deletable ones. The row-level trash cell is hidden on deleting rows.
2. **Deleting the currently-open session**: switch the editor away immediately when deletion starts (to the newest remaining non-deleting session, or a new session) while the row stays visible as "deleting…". On success the row disappears; on failure the row reverts to normal (clickable) and a notification is shown; the editor stays where it switched.
3. **Failure notification**: one error notification per failed session, titled with that session's name plus the error detail.
## Additional decisions (grounded in existing patterns, not blocking)
- **Clicking a deleting row is a no-op** (open is suppressed), mirroring `AgentManagerPanel.open` which returns early for `controller.isPending(item.id)`. Selection is still allowed so the toolbar reflects it.
- **Badges hidden while deleting** (a disappearing session should not show a RUNNING badge).
- **Greying** uses a new additive `ActiveListItem.muted` flag (default `false`) so the shared renderer change cannot affect the Providers/settings lists that already use `disabled`.
## Design / data flow
- **Pending-delete set** lives in `WorktreeSessionEditorManager` (EDT-only `LinkedHashSet<String>`), exposed as `deleting(): Set<String>`. The panel already queries the manager during `sync()` (`currentKey()`, `hasPendingNew()`, `activity()`), so it reads `deleting()` there too. Mutating the set is followed by `onListChanged?.invoke()` to re-sync rows.
- **Delete orchestration** moves to per-id so each session has independent pending state, success removal, and failure handling:
- `WorktreeSessionEditorManager.deleteSessions(ids)`:
1. `targets` = `ids` minus `NEW`, minus already-in-`deleting`, distinct. Return if empty.
2. Confirm dialog (unchanged messages; count = `targets.size`).
3. Add all `targets` to `deleting`; dispose their cached UIs (`forceSession`); `onListChanged`.
4. If `currentKey()``targets`: open fallback now = `latest()` (which must now skip `deleting` ids) else `newSession()`.
5. For each id call `list.delete(id) { ok, error -> ... }`:
- success: remove id from `deleting`, `onListChanged`, done (controller removed the row).
- failure: remove id from `deleting`, `onListChanged`, `notify(title, error)`.
- `WorktreeSessionListController.delete(ids, done)` → replace with per-id `delete(id, done: (Boolean, String?) -> Unit)`: `runCatching { service.deleteSession(id, dir) }`; on EDT, success → remove that row from `model` + telemetry + `done(true, null)`; failure → `LOG.warn` + `done(false, message)`; `reload()` only on failure to reconcile.
- **`latest()`** in the manager must ignore ids currently in `deleting` so the switch-away fallback never selects a disappearing session. (`start()` is unaffected — no deleting rows at startup.)
- **Notification** is injected into the manager like the existing `confirm` seam: `notify: (String, String?) -> Unit = { title, content -> KiloNotifications.error(project, title, content) }` (uses the manager's `project`), so tests capture it without real popups.
## Ordered tasks
1. **`ActiveListModel.kt`**: add `val muted: Boolean get() = false` to `ActiveListItem`.
2. **`ActiveListRenderer.kt`**: compute the title color as `val titleFg = if (value.muted) weak else fg` and use it for the title append (leaving badges/desc/trailing as-is). `weak` already resolves to the inactive/hint color when the row is not the active selection.
3. **`WorktreeSessionEditorManager.kt`**:
- Add `private val deleting = linkedSetOf<String>()` and `@RequiresEdt fun deleting(): Set<String> = deleting`.
- Add constructor seam `notify: (String, String?) -> Unit` (default calls `KiloNotifications.error(project, …)`), placed after `confirm` (keep existing default-arg order stable for tests).
- Rewrite `deleteSessions(ids)` per the design (targets filter, confirm, mark deleting, immediate switch-away with deleting-excluded fallback, per-id delete with success/failure handling + `notify`).
- Make `latest()` skip ids in `deleting`.
4. **`WorktreeSessionListController.kt`**: replace batch `delete(ids, done)` with per-id `delete(id, done: (Boolean, String?) -> Unit)` (runCatching, EDT model removal on success, warn + reconcile `reload()` on failure). Keep the existing `capture("Worktree Session Deleted", …)` telemetry on success.
5. **`WorktreeSessionEditorPanel.kt`**:
- In `sync()`, build rows as `SessionRow(it.session, kinds[it.id], deleting = it.id in manager.deleting())`.
- `SessionRow`: add `deleting: Boolean = false`; `trailing``KiloBundle.message("worktree.session.deleting")` when deleting else relative time; `cells``emptyList()` when deleting; `badges` → empty when deleting; add `override val muted get() = deleting`.
- `selectedKeys()` → also exclude `manager.deleting()` (drives DeleteAction disable + delete target set).
- `open(row, …)` → return early (no-op) when `row.key in manager.deleting()`.
- Wire `manager.notify` is set in the manager itself; no panel change needed unless the panel owns `project` (it does not) — keep notification inside the manager.
6. **`KiloBundle.properties`**: add
- `worktree.session.deleting=Deleting…`
- `worktree.session.delete.failed.title=Failed to delete session "{0}"`
7. **Frontend logging — `KiloSessionService.kt`**:
- `create(dir)`: standardize to `log.info("kind=session create=true dir=${ChatLogSummary.dir(dir)}")` before and `log.info("${ChatLogSummary.sid(session.id)} kind=session create=true ok=true …")` after.
- `deleteSession(id, dir)`: add `log.info("${ChatLogSummary.sid(id)} kind=session delete=true dir=${ChatLogSummary.dir(dir)}")` before `call { delete }` and `… ok=true` after.
8. **Backend logging — `KiloSessionRpcApiImpl.kt`**:
- `create`: keep `create session: directory=…`, then log the created id after `createSession()`.
- `delete`: add `log.info("delete session: id=$id, directory=$directory")` (matches existing backend log style like `prompt RPC: session=…`).
9. **Tests — frontend**:
- `FakeSessionRpcApi.kt`: add `var deleteThrows: Exception? = null`; throw it at the start of `delete(...)` (before recording) when set.
- `WorktreeSessionEditorPanelTest.kt`:
- `FakeManager` gains a mutable `deleting` set + `override fun deleting()`.
- `test deleting row shows deleting state`: mark a session deleting → assert its `SessionRow.trailing` == "Deleting…", `cells` empty, `muted` true, no badges.
- `test toolbar delete disabled when only deleting selected`: select only a deleting row → `DeleteAction` disabled; mixed selection → enabled and `deleteSelected()` targets exclude the deleting id.
- `test clicking deleting row does not open`: click a deleting row → `FakeManager.refs` unchanged.
- `WorktreeSessionEditorManagerTest.kt`:
- Add `notify` capture to the `manager()` factory.
- `test delete marks row deleting then removes on success`: use `rpc.deleteGate` to observe the intermediate `deleting()` membership, release, assert removal and empty `deleting()`.
- `test delete failure reverts row and notifies`: set `rpc.deleteThrows`, delete → assert row still present, `deleting()` empty, `notify` called once with the session title.
- Update `test deleting shown session removes it and falls back to next session` for immediate switch-away + `notify` seam.
10. **Tests — backend** (`KiloSessionRpcApiImplTest.kt`): inject a capturing `KiloLog` (see `backend/.../testing/TestLog.kt`) and assert a create log contains the created id and a delete log contains the deleted id. Keep assertions to "a line containing the id" to avoid brittleness.
## Failure modes / risks
- **Shared renderer change**: `muted` must stay additive (default `false`); verify Providers/settings lists render unchanged (their rows never set `muted`).
- **Controller API change**: `delete` signature changes; the manager is the only caller — update it and all tests that call the batch form.
- **Fallback selection**: if `latest()` is not updated to skip `deleting`, switch-away could reopen the session being deleted. Covered by task 3.
- **EDT discipline**: `deleting` is mutated only on EDT; RPC runs off-EDT via the controller and marshals callbacks back to EDT (existing `edt {}` helper).
- **Reconcile on failure**: controller `reload()` after a failed delete re-fetches server truth so the reverted row is consistent; the `deleting` flag is already cleared before re-sync.
- **Notification noise**: per-session notifications are intended (decision 3); acceptable for typical small multi-selects.
## Validation
- `./gradlew :frontend:test --tests ai.kilocode.client.agentManager.worktree.WorktreeSessionEditorPanelTest --tests ai.kilocode.client.agentManager.worktree.WorktreeSessionEditorManagerTest` (run from `packages/kilo-jetbrains/`).
- `./gradlew :backend:test --tests ai.kilocode.backend.rpc.KiloSessionRpcApiImplTest`.
- `./gradlew typecheck`.
- Manual smoke (optional): delete a session and confirm it greys, shows "Deleting…", trash icon hidden, toolbar disabled for it; force a failure to confirm revert + notification.
## Out of scope
- Sidebar `HistoryPanel` deletion styling.
- Changing the delete confirmation dialog.
- Any `kilocode_change` markers — `packages/kilo-jetbrains/` is entirely Kilo-owned.
## Open questions
None blocking.
@@ -0,0 +1,194 @@
# JetBrains Agent Manager — In-place rename (worktrees + sessions)
## Goal
Add in-place renaming to the JetBrains Agent Manager for:
1. **Worktree names** — the display name shown as the worktree list row title and the opened editor-tab title (NOT the git branch).
2. **Sessions** — the session title in the worktree session list.
Both use a **reusable rename popover** modeled on the existing delete popover: a small balloon anchored to the row, containing a text field prefilled with the current name, an OK button and the balloon's built-in X (close). The new name is written into the list **immediately (optimistic)**, then a "heavy" request runs; on failure the row reverts and a notification is shown.
## Key decisions (resolved with user)
- **Worktree name persistence:** backend RPC + repo state file. Store is a **separate JetBrains-owned file** `<mainRepoRoot>/.kilo/worktree-names.json` mapping absolute worktree path → custom name. (Cross-client sync with VS Code's `.kilo/agent-manager.json` is **out of scope**.)
- **Renamed name surfaces:** both the Agent Manager list row **and** the opened editor tab title.
- **Trigger:** the standard IntelliJ rename action (Shift+F6 / `RenameElement`) **plus** a per-row pencil action cell (shown when exactly one row is selected, next to delete).
- **Multi-select (session list only, which is `MULTIPLE_INTERVAL_SELECTION`):** rename resets the selection to the first eligible selected row, then opens the popover on it. The worktree list is single-selection, so this rule is a no-op there.
- **Division of responsibility:** the `ActiveList` UI owns the reusable popover + trigger orchestration and stays worktree/session-agnostic (parameterized by `current`/`commit` closures). Optimistic model update + heavy request + revert + notification live in the per-list controllers/managers, mirroring how delete is structured today.
## Constraints / invariants
- All Swing/model mutation on EDT (`@RequiresEdt`); RPC off EDT via existing coroutine scopes and `durable {}` / injected RPC pattern.
- No new Kotlin UI DSL / Compose / JCEF. Use `JBTextField`, theme APIs, `JBUI` spacing, `KiloBundle` strings.
- `packages/kilo-jetbrains/` is entirely Kilo-owned: **no `kilocode_change` markers** needed.
- **No CLI/SDK change required.** Worktree ops use the JetBrains backend git subprocess (`KiloWorktreeRpcApiImpl`), not `kilo serve`. Session rename uses the already-existing `renameSession` CLI PATCH path. So no CLI pin bump / `script/generate.ts`.
- Follow single-word naming, early returns, no empty catch, avoid `let` reassignment.
## Reference points in current code
- Delete popover: `ui/list/ActiveListDeletePopup.kt` (`ActiveListDeleteOptions`, `activeListDeleteContent`, `showActiveListDeletePopup`), `ActiveList.confirmDelete()`, `ActiveListView.point()/trackBalloon()`.
- Worktree list UI: `agentManager/AgentManagerPanel.kt` (`WorktreeRow`, `onCell`, `WorktreeDeleteProvider`).
- Worktree data flow: `agentManager/worktree/WorktreeController.kt`, `KiloWorktreeService.kt`, shared `rpc/KiloWorktreeRpcApi.kt`, backend `rpc/KiloWorktreeRpcApiImpl.kt`, `rpc/dto/WorktreeDto.kt`.
- Session list UI: `agentManager/worktree/WorktreeSessionEditorPanel.kt` (`SessionRow`, `confirmDelete`, `confirm` test seam).
- Session data flow: `WorktreeSessionListController.kt` (`delete`), `WorktreeSessionEditorManager.kt` (`deleteSessions`, `notify`), `app/KiloSessionService.kt` (`renameSession` already exists), shared `rpc/KiloSessionRpcApi.kt` (`rename`).
- Editor tab title: `agentManager/worktree/WorktreeSessionEditorKind.kt` (`title()`), refresh via `vfs/KiloVfsManager.updatePresentation(kind, params)`.
- Test patterns: `ui/list/ActiveListDeletePopupTest.kt`, `agentManager/WorktreeControllerTest.kt`, `agentManager/worktree/WorktreeSessionEditorManagerTest.kt`, `agentManager/worktree/WorktreeSessionEditorPanelTest.kt`.
---
## Implementation tasks (ordered)
### 1. Reusable rename popover (the shared "common class") — `ui/list/`
Create `ui/list/ActiveListEditPopup.kt` mirroring `ActiveListDeletePopup.kt`:
- `data class ActiveListEditOptions(val value: String, val label: String? = null, val button: String = KiloBundle.message("common.rename"))`.
- `internal fun activeListEditContent(opts, hide: () -> Unit, commit: (String) -> Unit): JComponent`:
- Vertical `Stack` (same border/gaps as delete content). Optional `label` line (`JBLabel`, context-help foreground). A `JBTextField` prefilled with `opts.value`, all text selected, reasonable `columns`. Right-aligned OK button built from an `AbstractAction(opts.button)` marked `DEFAULT_ACTION`.
- Enable OK only when the trimmed field text is non-blank **and** differs from `opts.value.trim()`. Wire a `DocumentListener` to re-sync enablement. Enter triggers OK (default action); blank/unchanged → OK disabled so Enter no-ops.
- OK → `hide(); commit(text.trim())`.
- `internal fun showActiveListEditPopup(anchor: RelativePoint, opts, commit): Balloon`: same balloon builder as delete (`setCloseButtonEnabled(true)` gives the X, `setRequestFocus(true)`, hide-on-click-outside/key-outside), show `below`, set default button, and request focus into the text field.
Extend `ui/list/ActiveList.kt` (keep agnostic — no worktree/session types):
- `fun editName(anchor: RelativePoint, opts: ActiveListEditOptions, commit: (String) -> Unit)``trackBalloon(showActiveListEditPopup(anchor, opts, commit))`.
- `fun rename(key: String, cell: String? = null, current: (String) -> String?, commit: (String, String) -> Unit)`:
- `select(key)` (resets a multi-selection down to this single row),
- `val value = current(key) ?: return`,
- `editName(point(key, cell), ActiveListEditOptions(value)) { name -> commit(key, name) }`.
- `fun renameSelected(current: (String) -> String?, commit: (String, String) -> Unit): Boolean`:
- `val key = selectedKeys().firstOrNull() ?: return false; rename(key, null, current, commit); return true`.
- (Callers pass a `current` that returns `null` for ineligible rows, e.g. the pending "New" session, so those are skipped.)
Strings — add to `frontend/.../resources/messages/KiloBundle.properties` (English; other locales fall back):
- `common.rename=Rename`
- `worktree.rename.action=Rename worktree`
- `worktree.rename.failed.title=Failed to rename worktree "{0}"`
- `worktree.session.rename.action=Rename session`
- `worktree.session.rename.failed.title=Failed to rename session "{0}"`
### 2. Shared RPC contract + DTO
- `shared/.../rpc/dto/WorktreeDto.kt`: add
`@Serializable data class RenameWorktreeResultDto(val worktree: WorktreeDto? = null, val error: String? = null)`.
(`WorktreeDto.name` is unchanged — the backend overlays the custom name into it.)
- `shared/.../rpc/KiloWorktreeRpcApi.kt`: add
`suspend fun rename(directory: String, path: String, name: String): RenameWorktreeResultDto`.
### 3. Backend worktree label store — `backend/.../rpc/KiloWorktreeRpcApiImpl.kt`
- Add pure, testable helpers (mirror existing `internal` `parseWorktreeList`/`managedWorktrees`):
- `internal fun overlayWorktreeNames(items: List<WorktreeDto>, names: Map<String, String>): List<WorktreeDto>` — for each non-main item, if `names[path]` is present and non-blank, return `copy(name = names[path])`.
- `internal fun readWorktreeNames(file: Path): Map<String, String>` / `internal fun writeWorktreeNames(file: Path, map: Map<String, String>)` — kotlinx.serialization `Json` over `Map<String,String>` (or a small `@Serializable` wrapper). Read tolerates missing/corrupt file (return empty; log). Write is atomic (temp file + `Files.move` `ATOMIC_MOVE`, creating `.kilo/`).
- Store location: resolve the **main** worktree root from `git worktree list --porcelain` (the first/`main==true` entry) and use `<mainRoot>/.kilo/worktree-names.json`. Keep it consistent regardless of which worktree `directory` the request came from.
- `list(directory)`: after `managedWorktrees(...)`, apply `overlayWorktreeNames(items, readWorktreeNames(store))`.
- `rename(directory, path, name)`:
- Trim `name`; empty → `RenameWorktreeResultDto(error = "Name is required")`.
- Resolve main root + store; read map; set `path -> name` (or remove entry when name equals derived path-segment name — optional normalization); atomic write.
- Return `RenameWorktreeResultDto(worktree = <derived dto for path>.copy(name = name))`. On IO error return `error`.
### 4. Frontend worktree service + name cache
- `agentManager/worktree/KiloWorktreeService.kt`: add
`suspend fun rename(directory: String, path: String, name: String): RenameWorktreeResultDto` (try/catch → `RenameWorktreeResultDto(error = ...)` on exception, matching `remove`).
- New light service `agentManager/worktree/WorktreeNameCache.kt` (`@Service(Service.Level.APP)`): in-memory `Map<String,String>` path→name with `get(path)`, `put(path, name)`, `remove(path)`, `putAll(items: List<WorktreeDto>)`. Used only to feed the editor-tab title synchronously.
### 5. Worktree rename wiring — `WorktreeController.kt` + `AgentManagerPanel.kt`
`WorktreeController`:
- On `reload()` success, `service<WorktreeNameCache>().putAll(rows)` so the cache tracks current names.
- Add `fun rename(dto: WorktreeDto, name: String, onFailure: (String?) -> Unit = {}, onSuccess: (WorktreeDto) -> Unit = {})`:
- `idx = model.getElementIndex(dto)`; if `< 0` return.
- **Optimistic:** `val row = dto.copy(name = name); model.setElementAt(row, idx)`; update cache.
- `cs.launch { val res = service.rename(directory, dto.path, name) ... }`:
- success (`res.worktree != null`): `model.setElementAt(res.worktree, idxOf)`, cache put, telemetry `Worktree Renamed`, `onSuccess(res.worktree)`.
- failure: revert element to `dto`, cache put(old), telemetry `Worktree Rename Failed`, `onFailure(res.error)`, then `reload()` to reconcile.
`AgentManagerPanel`:
- Add a pencil cell to `WorktreeRow.cells` (e.g. `AllIcons.Actions.Edit`, `iconOnly = true`, id `RENAME_CELL`) for non-main/non-pending rows.
- `onCell`: `RENAME_CELL``beginRename(item)`.
- `beginRename(item: WorktreeDto)`: `list.rename(item.id, RENAME_CELL, current = { key -> item(key)?.name }, commit = { key, name -> item(key)?.let { renameWorktree(it, name) } })`.
- `renameWorktree(dto, name)`: `controller.rename(dto, name, onFailure = { err -> KiloNotifications.error(project, KiloBundle.message("worktree.rename.failed.title", name), err) }, onSuccess = { updated -> project?.service<KiloVfsManager>()?.updatePresentation(WorktreeSessionEditorKind.ID, worktreeSessionParams(updated)) })`.
- Standard rename action: inner `RenameAction : AnAction` whose `update` enables when a single non-main/non-pending row is selected and `actionPerformed` calls `selectedRow()?.let { beginRename(it.dto) }`. In `init`, `renameAction.registerCustomShortcutSet(ActionManager.getInstance().getAction("RenameElement").shortcutSet, list, this)` so Shift+F6 works inside the list. (No global `renameHandler` EP.)
`WorktreeSessionEditorKind`:
- `title(params)`: `params[PATH]?.let { service<WorktreeNameCache>().get(it) ?: name(it) } ?: <fallback>`.
- (Best-effort startup reopen: optionally in `createContent`, launch a coroutine to `KiloWorktreeService.list(path)` / populate cache and `updatePresentation` if the persisted tab opened before the panel loaded. Mark optional; cache is normally warm because worktree editors are only opened from the panel.)
### 6. Session rename wiring — `WorktreeSessionListController.kt` + `WorktreeSessionEditorManager.kt` + `WorktreeSessionEditorPanel.kt`
`WorktreeSessionListController`:
- Add `fun rename(id: String, title: String, done: (Boolean, String?) -> Unit)`:
- find `SessionDto` in `model`; capture `prior`.
- **Optimistic:** `model.setElementAt(prior.copy(title = title), idx)`.
- `cs.launch { runCatching { service.renameSession(id, dir, title) } ... }`:
- success: `model.setElementAt(updated, idxOf)`, telemetry `Worktree Session Renamed`, `done(true, null)`.
- failure: revert to `prior`, `done(false, err?.message)`, then `reload()`.
`WorktreeSessionEditorManager`:
- Add `open fun renameSession(id: String, title: String)`:
- `list.rename(id, title) { ok, err -> onListChanged?.invoke(); if (!ok) notify(KiloBundle.message("worktree.session.rename.failed.title", title), err) }`.
- (`onListChanged` re-syncs the panel rows so the optimistic title shows immediately.)
`WorktreeSessionEditorPanel`:
- Add a test seam parallel to `confirm`: `edit: ((RelativePoint, ActiveListEditOptions, (String) -> Unit) -> Unit)? = null`, defaulting to `list.editName`.
- Add pencil cell to `SessionRow.cells` (id `RENAME_CELL`, shown together with delete when `selectedKeys().size == 1`).
- `onCell`: `RENAME_CELL``beginRename(key)`.
- `beginRename(key)`: skip `SessionHost.NEW`; `renameVia(key, RENAME_CELL)`.
- `renameSelected()` (for the action/keyboard path): pick `first = selectedKeys().firstOrNull { it != SessionHost.NEW && it !in manager.deleting() } ?: return`; `renameVia(first, null)``list.rename` will reset the multi-selection to `first`.
- `renameVia(key, cell)`: build `current = { k -> item(k)?.title?.takeIf { it.isNotBlank() } ?: KiloBundle.message("worktree.session.untitled") }` and `commit = { k, name -> manager.renameSession(k, name) }`; call `edit`-seam-aware equivalent of `list.rename(key, cell, current, commit)` (route the popover through the `edit` seam like delete routes through `confirm`).
- Toolbar + shortcut: add a `RenameAction` (icon `AllIcons.Actions.Edit`) to the toolbar group `[add, rename, delete]`; enable when ≥1 eligible row selected; `actionPerformed``renameSelected()`. Register `RenameElement` shortcut set on the list as in the worktree panel.
### 7. Test doubles
- `frontend/.../testing/FakeWorktreeRpcApi.kt` (or the fake used by `WorktreeControllerTest`): implement `rename(directory, path, name)`; add call tracking + configurable failure (`renameThrows`/`renameGate`) mirroring `deletes`/`deleteGate`.
- `frontend/.../testing/FakeSessionRpcApi.kt` already implements `rename`; add title/gate tracking if needed for the new controller test.
---
## Tests to add
- `ui/list/ActiveListEditPopupTest` (mirror `ActiveListDeletePopupTest`): OK disabled for blank/unchanged; enabled + `commit(trimmed)` + `hide` on OK; Enter path.
- `WorktreeControllerTest`: rename optimistic set → success replace; failure reverts + `onFailure(err)` + reconcile `reload()`; cache updated.
- Backend `KiloWorktreeRpcApiImplTest` (new or extend): `overlayWorktreeNames` overlays only non-main present entries; `readWorktreeNames`/`writeWorktreeNames` round-trip + missing/corrupt file tolerated + atomic write; `rename` persists and a subsequent `list` overlay reflects it (drive store helpers directly against a temp dir to avoid needing a real git repo).
- `WorktreeSessionEditorManagerTest`: rename marks optimistic title then keeps on success; failure reverts row and calls `notify` with `worktree.session.rename.failed.title`.
- `WorktreeSessionEditorPanelTest`: pencil cell present when exactly one selected; multi-select rename via `edit` seam resets selection to the first eligible row and opens with its current title; `NEW` row is never renamable.
- `AgentManagerPanel`/worktree UI test: pencil cell present for non-main rows; `RenameAction` enabled only for a single non-main/non-pending selection; commit path calls `controller.rename` and refreshes tab presentation on success.
- `KiloWorktreeServiceTest`: `rename` success maps result; exception → `RenameWorktreeResultDto(error=...)`.
## Failure modes to cover
- Backend store write failure → `RenameWorktreeResultDto(error)` → row reverts + notification; list reconciles via `reload()`.
- Session rename RPC failure → optimistic title reverts + notification; `reload()` reconciles.
- Blank or unchanged input → popover OK disabled, no request fired (no-op cancel).
- Rename while a row is `deleting` / pending `NEW` session → excluded (not eligible).
- Multi-select session rename → selection collapses to first eligible row before the popover opens.
- Editor tab for a renamed worktree that isn't currently open → nothing to refresh; next open reads the (now warm) cache. Persisted-tab-before-panel-load shows derived name until panel reload triggers `updatePresentation` (best-effort refresh optional).
## Validation
From `packages/kilo-jetbrains/`:
- `bun run typecheck` (or `./gradlew typecheck`).
- `./gradlew test` (targeted new/updated test classes first, then module suite).
- Run inspection `Plugin DevKit | Code | Frontend and Backend API Usage` since split-mode backend/shared/frontend code changes (new RPC method touches all three).
- Manual smoke in `runIde` / `runIdeSplitMode`: rename a worktree via pencil and via Shift+F6; confirm list row + open tab title update, and that the name survives an Agent Manager reload (persisted to `.kilo/worktree-names.json`). Rename a session via pencil and Shift+F6; force a failure (e.g. stop backend) to confirm revert + notification.
## Out of scope
- Cross-client name sync with VS Code's `.kilo/agent-manager.json`.
- Converting the sidebar History `RenameSessionAction` modal (`actions/RenameSessionAction.kt`) to the popover — the reusable popover makes this a straightforward follow-up but it is not part of this change.
- Renaming the main worktree or the git branch.
## Notes for implementer
- This requires editing source across `shared/`, `backend/`, and `frontend/` Kotlin modules plus a `.properties` bundle — switch to an implementation-capable agent.
- No `kilocode_change` markers (package is fully Kilo-owned). No CLI pin bump or SDK regeneration (worktree RPC is JetBrains-internal; session rename endpoint already exists).
@@ -0,0 +1,287 @@
# JetBrains Agent Manager — worktree activity badge
## Goal
Show a status badge on each **worktree row** in the JetBrains Agent Manager list
(`AgentManagerPanel`), aggregating the activity of that worktree's sessions — the same
`ActiveListBadge` the worktree **session list** already renders per session
(`WorktreeSessionEditorPanel.SessionRow.badges`).
Aggregation per worktree row:
1. If any session has a **non-running** kind (`PERMISSION`, `QUESTION`, `PLAN`), show that
(deterministic precedence below; "pick the first" per the request — order does not matter).
2. Else if any session is **running** (`busy`), show `RUNNING`.
3. Else **no badge**.
The badge must reflect real activity for **all** worktrees in the list, including worktrees
whose editor tab is not open.
## Resolved decision (with user)
**Data source = reactive global activity stream.** Add a backend-maintained, reactive
`sessionId → activity(kind, directory)` map — built from the global SSE stream the backend
already consumes (`session.status` + `question.asked/replied/rejected` +
`permission.asked/replied`) — exposed over a new Kilo-owned RPC that mirrors the existing
`statuses()` stream. The frontend groups by directory and aggregates; the Agent Manager row
looks up `activity[worktree.path]`. No polling, no CLI/SDK change.
### Why this shape (grounded in the code)
- The worktree session-list badges come from `WorktreeSessionEditorPanel.sync()`
`manager.activity()` (`Map<sessionId, SessionActivityKind>`), rendered via
`ActiveListBadge(kind.label(), kind.style())` (`WorktreeSessionEditorPanel.kt:336`).
- `RUNNING` is derived from the global reactive `KiloSessionService.statuses` (`busy`
`RUNNING`, `KiloSessionService.kt:94`). `QUESTION`/`PLAN`/`PERMISSION`/`LOGIN_REQUIRED` are
only derivable from a live `SessionController` (`SessionUi.activityKind()`,
`SessionUi.kt:245`); the status stream has no `busy`-vs-`question` distinction
(`session/status.ts` types: `idle|retry|busy|offline`). A session awaiting a question or
permission stays `busy` (the tool blocks the turn).
- The `AgentManagerPanel` worktree list is **not** a `SessionHost` and has no per-worktree
session data, so it needs an external source that also carries each active session's
**directory** (both to attribute `RUNNING` and to attribute `QUESTION`/`PERMISSION`).
- The backend already parses these events globally: `KiloBackendChatManager` re-emits
`ChatEventDto.{PermissionAsked,PermissionReplied,QuestionAsked,QuestionReplied,QuestionRejected,SessionStatusChanged}`
on its global `events` flow (`KiloBackendChatManager.kt:71-77`), and
`KiloBackendSessionManager` already tracks the global `statuses` StateFlow. No CLI change is
needed to observe them.
## Constraints / invariants
- `packages/kilo-jetbrains/` is entirely Kilo-owned: **no `kilocode_change` markers**.
- **No CLI/SDK change**: the events are already parsed by the backend; this is a new
Kilo-internal RPC (`shared` + `backend` + `frontend`). No `package.json` pin bump, no
`script/generate.ts`.
- Split-mode: define the RPC contract + DTOs in `shared` (`@Serializable`), implement in
`backend`, consume in `frontend` coroutines (never on EDT). Wrap the long-lived stream in
`durable {}` like `KiloSessionService.statuses`.
- All Swing/model mutation on EDT (`@RequiresEdt`); marshal flow emissions to EDT.
- Style/naming per `AGENTS.md`: single-word names, early returns, no `let`/`else` chains,
reuse `SessionActivityKind` + `ActiveListBadge` for rendering (no new colors/labels).
## Scope of kinds
- **In scope:** `RUNNING` (busy), `QUESTION`, `PLAN`, `PERMISSION`.
- **Out of scope (v1):** `LOGIN_REQUIRED`. It requires replicating
`isPaidModelAuthRequired` (`session/controller/PaidModelAuth.kt`) parsing plus the
stateful clear/dismiss transitions on the backend. Note as a follow-up; it does not block
the "running / question & etc" request.
---
## Data model (shared)
`shared/.../rpc/dto/SessionDto.kt` (next to `SessionStatusDto`):
- `@Serializable enum class SessionActivityKindDto { RUNNING, QUESTION, PLAN, PERMISSION }`
- `@Serializable data class SessionActivityDto(val directory: String, val kind: SessionActivityKindDto)`
`shared/.../rpc/KiloSessionRpcApi.kt`:
- Add `suspend fun activity(): Flow<Map<String, SessionActivityDto>>` (keyed by sessionID),
documented as "Observe live per-session activity (busy + pending question/permission) with
the session's directory," mirroring `statuses()`.
---
## Backend
### 1. `KiloBackendSessionManager` — expose session directory
Currently maps `SessionDto` (with `directory`) but does not cache sessionId → directory.
- Add `private val owned = ConcurrentHashMap<String, String>()`. In the private `dto(...)`
builder set `owned[id] = dir` (populated by every `list`/`recent`/`get`/`create` mapping).
- Add `fun sessionDirectory(id: String): String? = directories[id] ?: owned[id]` (prefers the
explicit worktree override, then the session's own directory).
- Clear `owned` in `stop()`.
### 2. New `KiloBackendActivityManager` (not an IntelliJ service)
New file `backend/.../app/KiloBackendActivityManager.kt`, owned by `KiloBackendAppService`
(started/stopped like `sessions`/`chat`). Mirrors `KiloBackendSessionManager` shape.
State (EDT-agnostic; guarded by its own coroutine/confined dispatcher or a single collector):
- reads `sessions.statuses` (StateFlow) for `busy` baseline (includes seeded statuses).
- `permissions: MutableMap<String /*sessionId*/, MutableSet<String /*permId*/>>`.
- `questions: MutableMap<String /*sessionId*/, MutableMap<String /*questionId*/, Boolean /*plan*/>>`
where `plan = request.questions.any { it.questionKey == "plan.followup.question" || it.headerKey == "plan.followup.header" }`
(mirrors `SessionUi.planFollowup`).
- `_activity = MutableStateFlow<Map<String, SessionActivityDto>>(emptyMap())`;
`val activity: StateFlow<...> = _activity.asStateFlow()`.
`start(sessions: KiloBackendSessionManager, chatEvents: SharedFlow<ChatEventDto>)`:
- launch: collect `sessions.statuses``recompute()`.
- launch: collect `chatEvents`; update the maps then `recompute()`:
- `PermissionAsked``permissions[sid] += request.id`
- `PermissionReplied``permissions[sid] -= requestID` (drop empty)
- `QuestionAsked``questions[sid][request.id] = planFollowup`
- `QuestionReplied` / `QuestionRejected``questions[sid] -= requestID` (drop empty)
- `SessionStatusChanged` with `type == "idle"` (and `SessionIdle`) → also drop that session's
permission/question overlays (defensive: a turn that ends without explicit replied/rejected
should not leave a stale overlay).
`recompute()`: for every sessionId that is `busy` OR has a pending permission/question, compute
`kind` by per-session precedence:
1. pending permission → `PERMISSION`
2. else pending question → `PLAN` if any pending question is a plan-followup, else `QUESTION`
3. else `busy``RUNNING`
Resolve `dir = sessions.sessionDirectory(sid)`; **skip** the entry when `dir == null` (unknown
directory — cannot attribute; rare, populated once the session is listed/created). Emit the new
`Map<sid, SessionActivityDto(dir, kind)>`.
`stop()`: cancel watchers, clear maps, `_activity.value = emptyMap()`.
### 3. Wire into `KiloBackendAppService`
- Add an `activity` field alongside `sessions`/`chat`.
- After `sessions.start(...)` and `chat.start(...)`, call `activity.start(sessions, chat.events)`
(`KiloBackendAppService.kt:459-461`). Add to `stop()`.
### 4. `KiloSessionRpcApiImpl`
- `override suspend fun activity(): Flow<Map<String, SessionActivityDto>> = app.activity.activity`
(mirror `statuses()` at `KiloSessionRpcApiImpl.kt:103`). Add an `activity` accessor
(`get() = app.activity`) like `sessions`/`chat`.
---
## Frontend
### 5. `KiloSessionService` — subscribe to the stream
Mirror the existing `statuses` wiring (`KiloSessionService.kt:65`):
```kotlin
val activity: StateFlow<Map<String, SessionActivityDto>> =
stream { activity() }.stateIn(cs, SharingStarted.Eagerly, emptyMap())
```
`FakeSessionRpcApi` (test double): add `val activity = MutableStateFlow<Map<String, SessionActivityDto>>(emptyMap())`
and `override suspend fun activity() = activity` (mirrors `statuses`).
### 6. Aggregation helper (pure, testable)
New `agentManager/worktree/WorktreeActivity.kt`:
```kotlin
internal fun aggregateWorktreeActivity(
activity: Map<String, SessionActivityDto>,
): Map<String, SessionActivityKind>
```
- Normalize each `directory` (trim trailing `/`) — worktree paths from git
(`WorktreeDto.path`) and session `directory` are both absolute; normalize both sides for the
lookup.
- Group by directory. For each directory pick one kind by precedence:
`PERMISSION` > `QUESTION` > `PLAN` > `RUNNING` (non-running first, then running). Map
`SessionActivityKindDto``SessionActivityKind`.
### 7. `WorktreeController` — hold + push aggregated activity
- Add injectable seam (keeps existing tests compiling):
`activity: StateFlow<Map<String, SessionActivityDto>> = MutableStateFlow(emptyMap())`
(production: `project.service<KiloSessionService>().activity`, passed from
`KiloToolWindowFactory`).
- Add `@Volatile var kinds: Map<String, SessionActivityKind> = emptyMap()` (directory → kind)
and `var onActivityChanged: (() -> Unit)? = null`.
- In an `init` block: `cs.launch { activity.collect { snap -> edt { kinds = aggregateWorktreeActivity(snap); onActivityChanged?.invoke() } } }`.
- Add `fun kind(path: String): SessionActivityKind? = kinds[path.trimEnd('/')]`.
### 8. `AgentManagerPanel` — render the badge
- `WorktreeRow`: add `val kind: SessionActivityKind?` and
`override val badges get() = listOfNotNull(kind?.let { ActiveListBadge(it.label(), it.style()) })`
(mirrors `WorktreeSessionEditorPanel.kt:336`). Hide the badge for `pending`/`deleting` rows
(return `emptyList()`), matching the session list's "no badge while transient" rule.
- `sync()`: pass `kind = controller.kind(item.path)` when building each `WorktreeRow`.
- `init`: set `controller.onActivityChanged = { sync() }`.
- `dispose()`: set `controller.onActivityChanged = null` (alongside the existing null-outs).
- `KiloToolWindowFactory.setup(...)`: construct
`WorktreeController(service<KiloWorktreeService>(), workspace.directory, cs, activity = project.service<KiloSessionService>().activity)`.
No `ActiveListRenderer` change — the worktree list already uses the same `ActiveList` +
renderer as the session list, and `ActiveListItem.badges` is already supported.
---
## Tests
Backend:
- `KiloBackendActivityManagerTest` (drive `ChatEventDto` + a `statuses` StateFlow through
`start`; seed `sessionDirectory` via a small fake/`MockCliServer`-backed
`KiloBackendSessionManager` or a seam):
- `busy` status + known dir → `RUNNING` at that directory.
- `PermissionAsked` overlays `RUNNING``PERMISSION`; `PermissionReplied` reverts to
`RUNNING` while still busy; `idle` clears the entry.
- `QuestionAsked` (plain) → `QUESTION`; plan-followup question keys → `PLAN`;
`QuestionReplied`/`QuestionRejected` revert.
- session with unknown directory is omitted from the map.
- `KiloBackendSessionManagerTest` (or existing): `sessionDirectory` returns the mapped dir
after a `list`/`create`, and the `setDirectory` override wins.
Frontend:
- `WorktreeActivityTest` (pure): precedence (non-running beats running; `PERMISSION` beats
`QUESTION` beats `PLAN`); multiple sessions in one directory aggregate to one kind; trailing
slash normalization; `SessionActivityKindDto``SessionActivityKind` mapping.
- `WorktreeControllerTest`: push a `SessionActivityDto` map through the injected `activity`
flow → `kinds` updates on EDT and `onActivityChanged` fires; `kind(path)` returns the
aggregated kind (with/without trailing slash).
- `AgentManagerPanelTest`: a worktree row shows the expected `ActiveListBadge` for a
running/question/permission directory; no badge when the directory has no active session;
no badge on `pending`/`deleting` rows.
- `FakeSessionRpcApi`: `activity` StateFlow added (see task 5).
## Failure modes / edge cases
- **Directory mismatch:** worktree `path` vs session `directory` string differences (trailing
slash, symlinks). Normalize trailing slash on both sides; note symlink normalization as a
residual risk (sessions are created with the worktree directory, so exact match is expected).
- **Unknown session directory:** a session that went busy before any list/create in this
backend lifetime has no cached dir → omitted until listed. Acceptable; the sidebar/worktree
editors list sessions, and `create` records dir.
- **Pending question/permission that predates subscription** (IDE reconnect while a question is
pending): only live events are tracked, so it may not appear until the next event. Matches how
the frontend also only recovers pending state on session open. Note as accepted v1 limitation
(optional follow-up: seed via `pendingQuestions`/`pendingPermissions` when the panel supplies
its worktree dirs).
- **Auto-approved permissions:** a transient `PermissionAsked``PermissionReplied` will briefly
show `PERMISSION` then revert to `RUNNING`. Acceptable (transient).
- **`retry`/`offline` statuses:** map to **no badge** (parity with
`KiloSessionService.activity()` and `SessionUi.activityKind()`, which only surface `busy`).
- **EDT discipline:** flow collection runs on `cs` (background); `kinds` mutation + `sync()` are
marshalled to EDT via the existing `edt {}` helper in `WorktreeController`.
## Validation
From `packages/kilo-jetbrains/`:
- `./gradlew typecheck` (compiles shared + backend + frontend + generated client).
- `./gradlew :backend:test --tests ai.kilocode.backend.app.KiloBackendActivityManagerTest` (+ session manager test).
- `./gradlew :frontend:test --tests ai.kilocode.client.agentManager.worktree.WorktreeActivityTest --tests ai.kilocode.client.agentManager.WorktreeControllerTest --tests ai.kilocode.client.agentManager.AgentManagerPanelTest`.
- Run inspection `Plugin DevKit | Code | Frontend and Backend API Usage` (new RPC method spans shared/backend/frontend).
- Manual smoke in `runIdeSplitMode`: create two worktrees, start a session in each; confirm a
`RUNNING` badge appears on a worktree row while its session is busy, upgrades to
`QUESTION`/`PERMISSION` when that session asks/needs approval (even when that worktree's editor
tab is not focused), and clears when idle.
## Out of scope
- `LOGIN_REQUIRED` aggregation (paid-model-auth detection on the backend) — follow-up.
- Seeding pre-existing pending questions/permissions on reconnect — follow-up.
- Any VS Code Agent Manager change (its worktree badges are PR/run-script status, a different
concept).
- `kilocode_change` markers / CLI pin bump / SDK regen (none required).
## Notes for implementer
- Editing spans `shared/`, `backend/`, and `frontend/` Kotlin modules — switch to an
implementation-capable agent.
- Reuse `SessionActivityKind.label()/style()` for rendering; do not introduce new badge
labels/colors. Add no new user-visible strings (badge labels already exist).
@@ -0,0 +1,158 @@
# Plan: Overlay ActiveList action button row on a layered pane
## Goal
Refactor `ActiveListRenderer` so its **action button row** (`cellPane` of `ActiveListActionCell`s) is
drawn **on top of** the row via a `JLayeredPane` instead of consuming horizontal layout space in
`BorderLayout.EAST`. The buttons keep their current right-aligned position and internal layout, but
float on a top layer. A small padding of the **same background color** wraps the button bar so it
blends into the row ("empty" surface, not a separate color). Because every ActiveList-based list
(settings lists, autoapprove, rules, providers, MCP, skills, agents, worktree editor, agent manager)
shares this single renderer, they all get the behavior automatically.
## Target and scope
- **File:** `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt`
- **In scope:** only the action buttons (`cellPane`). Trailing text (`trailPane`), leading icon
(`mark`), title/description (`textPane`), and the section header (`top`) stay in normal layout.
- **Out of scope:** `PickerListRenderer<T>`, `HistoryRenderer`, `AccountPickerRenderer` (they share the
`PickerRow` wrapper but are not the "active renderer" and are not part of this change).
- Kilo-owned path (`ui/list/`), no upstream `kilocode_change` markers needed.
## Current structure (for reference)
- Root: `JPanel(BorderLayout)``top` (NORTH, section separator) + `wrap: PickerRow` (CENTER).
- `wrap.setContent(row)`; `row: JPanel(BorderLayout)` = `mark` (WEST) + `textPane` (CENTER) +
`actions` (EAST). `actions = Stack.horizontal(md).next(trailPane).next(cellPane)`.
- `cellPane = cells.align(RIGHT, CENTER)`; `cells: Stack.horizontal` holds `ActiveListActionCell`s.
- Visibility: cells shown only for the active focused selection or `alwaysVisible` cells
(`syncCells(value, active && list.isEnabled, ...)`).
- Clicks/tooltips/balloon anchors resolve via `activeListCellBounds()` /
`activeListCellAt()` (`ActiveListModel.kt`), which re-render the cell, recursively `doLayout` it
(`activeListLayout`), find every visible `ActiveListActionCell`, and convert its coordinates to the
root component. **Geometry is read back from the live tree, so click targets follow wherever the
cells are drawn — no separate coordinate math to keep in sync.**
## Key design decisions
1. **Layer host:** reuse the existing `LayeredOverlayPanel`
(`ui/LayeredOverlayPanel.kt`) as the content of `wrap`, instead of hand-rolling a new
`JLayeredPane`. It already provides content (`DEFAULT_LAYER`) + overlay (`PALETTE_LAYER`) layers,
a `doLayout` that sizes every layer to full bounds and re-lays children, an `addOverlay(child) {}`
bounds hook, and `getPreferredSize = max(content, overlay)`. The unused `blocker` layer stays
hidden and inert (a paint-only rubber-stamp renderer never receives events, so its `contains`
override is irrelevant). Only one renderer instance exists per list, so the extra layer is cheap.
- If reuse proves awkward, fall back to a minimal private `JLayeredPane` in this file mirroring
`LayeredOverlayPanel.doLayout` (content + overlay only). Prefer reuse.
2. **Content layer** = the existing `row`, with `cellPane` **removed** from `actions`:
- `row` = `mark` (WEST) + `textPane` (CENTER) + `trailPane` (EAST). Keep the row border/padding.
- `textPane` now reclaims the horizontal space the buttons used to reserve (full width minus
leading icon and trailing text).
3. **Overlay layer** = a new opaque **pill** panel containing `cellPane`:
- Pill = a `JPanel` (BorderLayout/Align) wrapping `cellPane`, `isOpaque = true`, with a small
uniform padding border around the buttons (`JBUI.Borders.empty(UiStyle.Gap.sm())`; tune to
`xs()` if `sm()` looks too large). This is the "small padding of the same color".
- Register with `layers.addOverlay(pill) { host, child -> rightAlignedRect }`: right edge aligned
to the row content's right inset (`UiStyle.Gap.pad()`, matching where cells sat before),
vertically centered, sized to `child.preferredSize`.
- Pill visibility mirrors current cell visibility (hidden when there are no visible cells).
4. **Background = same color as the row (blend):** on each `getListCellRendererComponent`, set the
pill background to the row's **effective** surface so it reads as empty space:
- selection color when the row is the active focused selection
(`UIUtil.getListBackground(true, focused)` — the same value `PickerRow.update` uses as
`selectionColor`), otherwise `list.background` (covers `alwaysVisible` cells on unselected rows,
and the tool-window surface via `list.background`).
- The `LayeredOverlayPanel` itself and the content `row` remain non-opaque/transparent so the
`PickerRow` selection highlight still paints through beneath the content.
5. **Preferred size / row height:** rely on `LayeredOverlayPanel.getPreferredSize = max(content,
overlay)` so rows never clip the button pill even if a row's content is shorter than the buttons.
`ActiveListView.syncCellHeight` / `renderer.bodyPreferredHeight` measure with `selected=true,
focused=true` (pill visible), so equal-height mode stays consistent across rows.
6. **Hit-testing stays correct for free:** `activeListCellBounds` calls `activeListLayout(comp)` which
recursively `doLayout`s the tree, including `LayeredOverlayPanel.doLayout` (positions the pill) and
the pill/`cellPane`. It then finds the `ActiveListActionCell`s and converts coordinates to the
root. Since it renders with `focused=true`, the pill is visible during measurement. `point(key,
cell)` (balloon anchors for delete/edit/level popups) reads the same bounds, so anchors follow the
overlaid buttons automatically. **No changes required in `ActiveListModel.kt`; verify only.**
## Failure modes / risks
- **Overlay not on top:** confirm cells land on `PALETTE_LAYER` (above `DEFAULT_LAYER`). The JList
rubber stamp validates the renderer before paint (`BasicListUI` calls
`rendererPane.paintComponent(..., shouldValidate = true)`), so custom `doLayout` runs and layer
z-order paints overlay last. Existing `LayeredOverlayPanelTest` / `SessionRootPanelTest` already
assert this layer assignment.
- **Content bleeding past the pill:** the pill is a single opaque panel behind all buttons, so text
behind it is masked; title text to the left of the pill remains visible (intended float look).
- **Pill clipping when row is short:** mitigated by `max(content, overlay)` preferred height.
- **Right-edge alignment drift:** balloons and clicks read back live geometry, so they self-align;
only the *visual* right inset must look right — match the previous `UiStyle.Gap.pad()` right inset.
- **New UI vs classic:** `PickerRow.update` adjusts selection insets in New UI. Keep the pill's
right inset independent of that; verify visually in both UIs is not required, but keep the inset a
`JBUI`/`UiStyle.Gap` value (DPI-aware).
## Implementation tasks (ordered)
1. In `ActiveListRenderer`, remove `cellPane` from the `actions` stack so `actions` (or the row EAST)
holds only `trailPane` (trailing text). Keep `trail`/`trailPane` behavior and the
`actions.isVisible = trail.isVisible || cellPane.isVisible` logic re-expressed for the new layout
(trailing-text visibility drives the EAST slot; pill visibility is handled on the overlay).
2. Introduce the opaque `pill` panel wrapping `cellPane` with `JBUI.Borders.empty(UiStyle.Gap.sm())`
padding and `isOpaque = true`. Do **not** run it through `UiStyle.Components.transparent(...)`
(it must paint the blend background); keep `cellPane`/`cells` transparent as today.
3. Replace `wrap.setContent(row)` with `wrap.setContent(layers)` where
`layers = LayeredOverlayPanel(content = row)` and `layers.addOverlay(pill) { host, child -> ... }`
computes a right-aligned, vertically-centered rectangle inset by `UiStyle.Gap.pad()` on the right.
4. In `getListCellRendererComponent`:
- keep populating `cells` via `syncCells(...)` exactly as now;
- set `pill.isVisible = cells.isVisible`;
- set `pill.background` to `if (active && list.isEnabled) UIUtil.getListBackground(true, focused/active) else list.background` (match `PickerRow.update`'s selection color);
- drop the old `cellPane.isVisible` / `actions.isVisible`-with-cells coupling in favor of the pill.
5. Ensure `LayeredOverlayPanel` and `row` remain non-opaque so the `PickerRow` selection highlight
still shows through; only the pill is opaque.
6. Confirm `ActiveListModel.activeListCellBounds` / `activeListCellAt` need no change (the traversal
already recurses into the layered pane). Add a code comment noting the cells now live in the
overlay layer.
## Tests
Add/adjust in `packages/kilo-jetbrains/frontend/src/test/...`:
1. New assertions in `ui/list` (or extend `SettingsListViewTest`):
- action cells resolve onto the overlay/`PALETTE_LAYER` above the content layer;
- the pill background equals the row's selection background when selected/active, and
`list.background` for an `alwaysVisible` cell on an unselected row;
- `textPane`/title occupies the reclaimed full width (cells no longer reserve EAST space);
- `activeListCellBounds(...)` still returns a non-empty rect for a visible cell and
`activeListCellAt(center)` maps to it; a click at that point routes through `onCell`.
2. Run and fix the existing geometry/round-trip tests that exercise action cells:
`SettingsListViewTest`, `ProvidersSettingsUiTest`, `AutoApproveSettingsUiTest`,
`SettingsInlineListTest`, `RulesSettingsUiTest`, `SkillsSettingsUiTest`, `McpSettingsUiTest`,
`AgentsSettingsUiTest`, `WorktreeSessionEditorPanelTest`. Most use get-bounds → click-center →
assert-callback and should pass unchanged; fix any that assert the old EAST layout specifics
(e.g. exact non-action visible-component counts or that the title is not overlapped).
3. Follow the retained-Swing test guidance in the JetBrains `AGENTS.md`: assert no per-render
component growth and that the same `ActiveListActionCell` instances are reused across updates.
## Validation
From `packages/kilo-jetbrains/`:
- `./gradlew typecheck`
- `./gradlew test` (or targeted: the test classes listed above)
- Optional manual check: `./gradlew runIde`, open a settings list (e.g. Rules or Providers) and the
Agent Manager worktree list; confirm action buttons float on top on selection/hover, blend into the
row background with a small pad, and that click/edit/delete balloons anchor correctly in both New UI
and classic UI, light and dark themes.
## Notes for the implementer
- This is a frontend-only Swing change on the EDT; annotate any touched UI methods per existing
`@RequiresEdt` usage and keep all mutation on the EDT.
- Use `UiStyle.Gap.*` / `JBUI` for all spacing and `UIUtil.getListBackground(...)` for the blend
color — no raw `Color`, `Insets`, or pixel literals (see `AGENTS.md` UI rules).
- No SDK/server changes; no CLI pin impact.
@@ -0,0 +1,219 @@
# JetBrains Agent Manager: worktree list follows the selected editor tab
## Goal
Make the Agent Manager worktree list selection a live projection of the currently
**selected editor tab**:
- When the selected editor tab is a worktree editor, select that worktree's row.
- When the selected editor tab is anything else (or there is no editor), the list has **no** selection.
- Keep the "which editor belongs to which worktree" decision **pluggable** so future editor kinds
(e.g. a worktree diff view) can contribute matches. For now only the worktree **session** editor
is recognized.
Decision (confirmed with user): track the *selected editor tab* via
`FileEditorManagerListener.selectionChanged`, not raw window focus. The row stays highlighted while
the user interacts with the Agent Manager tool window and only clears when the selected editor tab
switches to a non-worktree file. This is the canonical platform API (all callbacks fire on EDT), no
hacks.
## Constraints / context
- All code lives in `packages/kilo-jetbrains/` (frontend module), which is entirely Kilo-owned — **no
`kilocode_change` markers needed** and the opencode annotation check does not apply.
- `WorktreeDto.id == WorktreeDto.path` (absolute path is the stable key). The worktree session editor
stores that path in `KiloPath.params["path"]` for kind `WorktreeSessionEditorKind.ID`.
- EDT-only: `FileEditorManagerListener` callbacks are on EDT; `ActiveList` mutations require EDT.
- Existing `AgentManagerPanel.selected: String?` already holds the selected worktree key and
`sync()` re-applies it after every model replace.
## Affected / new files
1. **New** `frontend/.../client/agentManager/worktree/WorktreeEditorMatcher.kt`
- Pluggable matcher interface, its project-level registry service, and the default session matcher.
2. **Edit** `frontend/.../client/ui/list/ActiveList.kt` and `ActiveListView.kt`
- Add a public `clearSelection()` so the panel can express "no selection".
3. **Edit** `frontend/.../client/agentManager/AgentManagerPanel.kt`
- Register the default matcher, subscribe to editor-selection changes, drive list selection.
4. **Edit** `frontend/.../client/agentManager/AgentManagerPanelTest.kt`
- Add coverage for tab-selection tracking, clearing, and matcher pluggability.
## Design
### 1. Pluggable matcher (`WorktreeEditorMatcher.kt`)
```kotlin
package ai.kilocode.client.agentManager.worktree
import ai.kilocode.client.vfs.KiloVirtualFile
import com.intellij.openapi.components.Service
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.util.concurrency.annotations.RequiresEdt
import java.util.concurrent.CopyOnWriteArrayList
/**
* Resolves the worktree path an editor file belongs to, or null when the file is not a worktree
* editor. Pluggable so future editor kinds (e.g. a worktree diff view) can contribute matches.
*/
fun interface WorktreeEditorMatcher {
@RequiresEdt
fun match(file: VirtualFile): String?
}
/** Project-level registry of [WorktreeEditorMatcher]s consulted in registration order. */
@Service(Service.Level.PROJECT)
class WorktreeEditorMatchers {
private val matchers = CopyOnWriteArrayList<WorktreeEditorMatcher>()
fun register(matcher: WorktreeEditorMatcher) {
matchers.addIfAbsent(matcher)
}
@RequiresEdt
fun match(file: VirtualFile?): String? {
if (file == null) return null
return matchers.firstNotNullOfOrNull { it.match(file) }
}
}
/** Default matcher: the worktree session editor tab. */
object WorktreeSessionEditorMatcher : WorktreeEditorMatcher {
override fun match(file: VirtualFile): String? {
val kilo = file as? KiloVirtualFile ?: return null
if (kilo.path.kind != WorktreeSessionEditorKind.ID) return null
return kilo.path.params["path"]?.takeIf { it.isNotBlank() }
}
}
```
Notes:
- Project-level registry: gives a fresh instance per project (no cross-test leakage) and matches
where future project-scoped matchers (diff editors) will need to look up worktree paths.
- The default matcher is a stateless singleton `object`; `addIfAbsent` makes registration idempotent.
### 2. `ActiveList` / `ActiveListView` — expose `clearSelection()`
`ActiveListView` already calls `list.clearSelection()` internally; expose it:
```kotlin
// ActiveListView
@RequiresEdt
fun clearSelection() {
checkEdt()
list.clearSelection()
}
```
```kotlin
// ActiveList
@RequiresEdt
fun clearSelection() = view.clearSelection()
```
### 3. `AgentManagerPanel`
Imports to add: `com.intellij.openapi.fileEditor.FileEditorManagerEvent`,
`com.intellij.openapi.fileEditor.FileEditorManagerListener`,
`com.intellij.openapi.vfs.VirtualFile`, and the new
`ai.kilocode.client.agentManager.worktree.WorktreeEditorMatcher` / `WorktreeEditorMatchers` /
`WorktreeSessionEditorMatcher`.
In `init` (only when `project != null`):
- Register the default matcher: `project.service<WorktreeEditorMatchers>().register(WorktreeSessionEditorMatcher)`.
- Subscribe to editor selection and seed the initial state:
```kotlin
project?.let { p ->
p.service<WorktreeEditorMatchers>().register(WorktreeSessionEditorMatcher)
p.messageBus.connect(this).subscribe(
FileEditorManagerListener.FILE_EDITOR_MANAGER,
object : FileEditorManagerListener {
override fun selectionChanged(event: FileEditorManagerEvent) = track(event.newFile)
},
)
track(FileEditorManager.getInstance(p).selectedFiles.firstOrNull())
}
```
Add the tracking helper (source of truth = active editor tab):
```kotlin
@RequiresEdt
private fun track(file: VirtualFile?) {
val key = project?.let { it.service<WorktreeEditorMatchers>().match(file) }
selected = key
if (key == null || !list.select(key, scroll = false)) list.clearSelection()
}
```
Update `sync()` so "no selection" is sticky across model replaces:
```kotlin
private fun sync() {
val key = selected
list.update( /* unchanged rows */, ActiveListSelection.PreserveNoScroll)
if (key != null) list.select(key, scroll = false) else list.clearSelection()
}
```
Replace `activeWorktreeKey()` (which scanned *all* open worktree editors) and rewire `refresh()` to
use the active editor tab via the matcher:
```kotlin
fun refresh() {
selected = currentEditorWorktree()
controller.reload()
}
private fun currentEditorWorktree(): String? {
val p = project ?: return null
return p.service<WorktreeEditorMatchers>().match(
FileEditorManager.getInstance(p).selectedFiles.firstOrNull(),
)
}
```
Delete the old `activeWorktreeKey()` method (its `KiloVirtualFile`-scanning logic now lives in
`WorktreeSessionEditorMatcher`; drop the now-unused `KiloVirtualFile` import if nothing else uses it).
### Behavior / interaction notes
- Clicking a list row calls `onOpen -> open(item, focus=false)`, which selects that editor tab and
fires `selectionChanged`; `track` then re-selects the same row (no loop, `selected` unchanged).
- `controller.onSelect` (create/quick-create flow) still selects+focuses the freshly created row; the
subsequent editor open re-affirms the same selection.
- Focusing the Agent Manager tool window does **not** fire `selectionChanged`, so the row stays
highlighted — matching the requirement.
- Switching to a normal code file fires `selectionChanged(newFile = code file)` → matcher returns
null → `clearSelection()`.
## Validation
From `packages/kilo-jetbrains/`:
- `./gradlew typecheck`
- `./gradlew test` (or targeted: the `AgentManagerPanelTest` class).
Add tests to `AgentManagerPanelTest` (extends `BasePlatformTestCase`, uses the existing `edt`/`flush`
helpers and `myFixture` for real files):
1. **Selecting a worktree editor tab selects its row** — with the panel + a loaded worktree, open the
worktree session editor via `project.service<KiloVfsManager>().open(...)` (focus=true); assert
`list.selectedValue.key == worktree.id`.
2. **Selecting a non-worktree editor clears the row** — after (1), open a normal file
(`myFixture.addFileToProject(...)` + `FileEditorManager.getInstance(project).openFile(vf, true)`);
assert `list.selectedIndex == -1`.
3. **No selection when the active tab isn't a worktree on open** — with a normal file already the
selected editor, create the panel + reload; assert `list.selectedIndex == -1`.
4. **Matcher pluggability** — register a custom `WorktreeEditorMatcher` on
`project.service<WorktreeEditorMatchers>()` that maps a normal file's path to a worktree path
present in the list; open that normal file; assert the corresponding row becomes selected. Proves
the pluggable check drives selection for a non-session editor.
Confirm the existing `test refresh selects active worktree editor` still passes (it opens the
worktree editor with focus, so it remains the active tab and resolves via the matcher).
## Out of scope
- Implementing a worktree **diff** editor matcher (only the interface + registry are added now).
- Any backend/RPC changes — this is a frontend-only interaction change.
- Multi-split "which split wins" refinements beyond `selectedFiles.first()` / `event.newFile`.
@@ -0,0 +1,149 @@
# Plan: Hover-revealed action bar for worktree & session lists
## Goal
In the Agent Manager JetBrains UI, make the `ActiveList` action-cell "button bar" appear
**only for the row the mouse is currently hovering**, instead of on the active focused
selection. Leaving the row (or the list) hides the bar again. Repaint must be scoped to the
affected row cell only — no full-list repaint.
Scope this new behavior to exactly two lists:
- Worktree list — `AgentManagerPanel` (`agentManager/AgentManagerPanel.kt:68`)
- Worktree-editor session list — `WorktreeSessionEditorPanel` (`agentManager/worktree/WorktreeSessionEditorPanel.kt:63`)
All other `ActiveList` consumers (settings pages, session history, pickers) keep the current
selection-based reveal unchanged.
## Confirmed decisions
- **Hover replaces selection reveal** in the two target lists. When hover mode is on, the bar
shows only on the hovered row; the selected/focused row does not show the bar unless it is
also the hovered row. `ActiveListCell.alwaysVisible` cells still always show.
- **"Cell" = whole list row.** Hovering anywhere on a row reveals that row's entire bar;
repaint is scoped to that row's `getCellBounds(idx, idx)`.
- Row selection highlighting (`wrap.update(list, selected, active)`) is unchanged — only the
action-cell visibility + pill visibility switch to hover-driven in hover mode.
## Key implementation facts
- Action-cell visibility is decided in `ActiveListRenderer.getListCellRendererComponent`
(`ui/list/ActiveListRenderer.kt:164-167`) via `syncCells(value, active && list.isEnabled, ...)`,
then `cellPane.isVisible`, `pill.isVisible`, `pill.background`.
- `active` derives from selection + focus + `(list as? ActiveListActive)?.active()`
(renderer line 122).
- The `JBList` is created inside `ActiveListView` and already implements `ActiveListActive`
(`ui/list/ActiveListView.kt:50`). Hover state can live in `ActiveListView` and be exposed to
the renderer through that interface.
- Row height is precomputed with cells rendered (`syncCellHeight` renders with
`selected/focused = true`, `bodyPreferredHeight` likewise), so showing/hiding cells on hover
causes **no layout jump**. No height changes needed.
- Cell hit-testing (`activeListCellBounds`/`activeListCellAt` in `ui/list/ActiveListModel.kt`)
already renders with `focused = true` to resolve click targets regardless of paint-time
visibility, so clicking a hover-revealed cell already works with no change.
## Implementation tasks
1. **`ui/list/ActiveListModel.kt` — add config flag.**
- Add `val hoverActions: Boolean = false` to `ActiveListConfig` (default false preserves all
existing lists). Keep `Equal` / `Preferred` companion values as-is.
2. **`ui/list/ActiveListRenderer.kt` — expose hovered index + hover-driven visibility.**
- Extend `ActiveListActive` with `fun hoveredIndex(): Int = -1` (default keeps other
implementers, if any, working).
- In `getListCellRendererComponent`, compute:
```
val showCells = if (cfg.hoverActions)
list.isEnabled && index == (list as? ActiveListActive)?.hoveredIndex()
else
active && list.isEnabled
```
- Replace the `syncCells(value, active && list.isEnabled, list.isEnabled)` call and the
`pill.background = if (active && list.isEnabled) ...` line to use `showCells`. `cellPane`
and `pill` visibility already follow `cells.isVisible`, which `syncCells` sets from the
passed flag — no further change there.
- Do not change `active`/`fg`/`weak`/`wrap.update` (selection highlight stays as-is).
3. **`ui/list/ActiveListView.kt` — track hover, repaint only the affected rows.**
- Add `private var hovered = -1`. Override `hoveredIndex(): Int = hovered` on the inline
`JBList` `ActiveListActive` implementation.
- Convert the existing inline `MouseAdapter` (added at `ActiveListView.kt:109`) into a named
local `val`, add `mouseMoved` and `mouseExited` overrides, and register it with
`list.addMouseMotionListener(...)` **only when `cfg.hoverActions`** (avoid overhead for
other lists). Keep `list.addMouseListener(...)` for all lists as today.
- `mouseMoved`: resolve row via `list.locationToIndex(e.point)`, keep it only if
`>= 0` and `getCellBounds(i,i)?.contains(e.point) == true`, else `-1`; call
`setHovered(idx)`.
- `mouseExited`: `setHovered(-1)`.
- Add helpers:
```
private fun setHovered(idx: Int) {
if (hovered == idx) return
val old = hovered
hovered = idx
repaintRow(old); repaintRow(idx)
}
private fun repaintRow(idx: Int) {
if (idx < 0) return
list.getCellBounds(idx, idx)?.let { list.repaint(it) }
}
```
- In `sync(...)` (model replace) reset `hovered = -1` before/after `model.replaceAll(rows)`
so a stale index can't point at a shifted row. `setBusy(true)` path: also clear hover
(call `setHovered(-1)`), so a disabled list shows no bar.
- All new methods run on EDT (list mouse events + `sync` already are); annotate helpers
consistent with surrounding code (`sync` is already `@RequiresEdt`).
4. **`agentManager/AgentManagerPanel.kt` — enable hover mode.**
- Pass `cfg = ActiveListConfig(hoverActions = true)` to the `ActiveList(...)` constructor
(line 68). It currently relies on the default `ActiveListConfig.Equal`; the new value keeps
`Equal` defaults (height EQUAL, description true, single selection) plus `hoverActions`.
5. **`agentManager/worktree/WorktreeSessionEditorPanel.kt` — enable hover mode.**
- Add `hoverActions = true` to the existing `ActiveListConfig(...)` at line 65 (keep
`ActiveListRowHeight.EQUAL`, `description = false`,
`selection = MULTIPLE_INTERVAL_SELECTION`).
## Tests
Add coverage exercising the real component tree (extend the existing
`BasePlatformTestCase`-style setups in `AgentManagerPanelTest` and
`WorktreeSessionEditorPanelTest`). Reuse the existing `fire(list, MouseEvent(...))` pattern
(AgentManagerPanelTest.kt:135) and `getCellBounds` to position events.
Assertions (render via `list.cellRenderer.getListCellRendererComponent(...)` and walk for
visible `ActiveListActionCell` instances, mirroring `activeListActionCells` traversal, or check
`cellPane`/`pill` visibility):
- Hover-mode list with no hover: no row shows action cells even when a row is selected + list
focused (verifies selection reveal is replaced).
- After a `MOUSE_MOVED` over row N: only row N renders its action cells; other rows do not.
- After `MOUSE_EXITED`: no row shows action cells.
- Moving hover from row A to row B repaints only A and B (assert observable: B shows cells, A
hidden; a full-repaint assertion is not required — scope is validated by `repaintRow` using
cell bounds).
- `alwaysVisible` cells still render on non-hovered rows (if any target row defines one).
- Regression: a non-hover list (e.g. an existing settings list or a plain `ActiveListConfig`)
still reveals cells on active focused selection.
- `setBusy(true)` clears hover so no bar shows.
## Validation
From `packages/kilo-jetbrains/`:
- `./gradlew typecheck`
- `./gradlew test` (or target the two panel test classes / the list test package)
Requires Java 21; only check Java if Gradle reports a Java error.
## Risks / notes
- Confine all changes to `ui/list/` + the two `agentManager/` panels. No RPC, shared-opencode,
or CLI changes — no `kilocode_change` markers involved (all Kilo-owned JetBrains frontend).
- No row-height recompute is needed because height already accounts for the bar; if a future
change makes cells taller than the row, revisit `syncCellHeight`.
- Keep single-word naming per repo style (`hovered`, `idx`, `old`, `cfg`).
## Out of scope
- Changing selection highlight behavior.
- Hover behavior for any list other than the two named ones.
- Per-individual-button hover granularity (explicitly rejected — whole-row reveal).
@@ -0,0 +1,189 @@
# Migrate History list + renderer to ActiveList (hover-reveal delete)
## Goal
Replace the bespoke `JBList` + `HistoryRenderer` in the session History panel with the shared
`ActiveList` framework (`ui/list/`), and adopt `ActiveListConfig(hoverActions = true)` so the local
delete button is revealed on hover (matching Agent Manager) and confirmed via the shared balloon
popup instead of a modal dialog.
All touched code is under `packages/kilo-jetbrains/` (a `kilo`-named path), so **no `kilocode_change`
markers are needed** anywhere in this work.
## Decisions (resolved)
- **Delete confirm:** use the ActiveList balloon (`ActiveListDeleteOptions` + `ActiveList.confirmDelete`),
replacing `Messages.showYesNoDialog`. Multi-select local delete anchors the balloon to one selected
row and confirms the whole batch.
- **Shell stays:** keep `HistoryPanel`'s tabs, per-tab `SearchTextField`, repo-only checkbox,
Load-more footer, 3s activity timer, `DataProvider`, and context menu. Swap only the inner list +
renderer.
- **Search stays server/paging-aware:** external search keeps driving `HistoryModel.setFilter`; the
panel feeds already-filtered `model.visibleItems` into `activeList.update(...)`. `ActiveList` runs
with `showSearch = false` and no client-side matcher filtering (filter query stays empty).
- **Hover semantic (document, do not change):** `ActiveListRenderer.kt:163` shows the action pill only
when a row is `selected && hovered`. This is the "same effect" as Agent Manager. The delete button
therefore appears when hovering the selected row, and the persistent trailing time is overlaid by
the delete pill only then.
## Key existing references
- History: `session/history/HistoryPanel.kt`, `HistoryListRenderer.kt` (to delete),
`HistoryModel.kt`/`CloudHistoryModel`, `HistoryController.kt`, `HistoryItem.kt`, `HistoryTime.kt`,
`HistoryActivitySnapshot.kt`, `HistoryDataKeys.kt`, `HistoryListUi.kt`.
- ActiveList: `ui/list/ActiveList.kt`, `ActiveListModel.kt` (`ActiveListItem`, `ActiveListConfig`,
`ActiveListCell`, `ActiveListBadge`), `ActiveListRenderer.kt`, `ActiveListView.kt`,
`ActiveListDeletePopup.kt`.
- Reference consumer to mirror: `agentManager/AgentManagerPanel.kt` (`WorktreeRow`, `hoverActions`,
`onCell`, `confirmDelete`, `showDeletePopup`, `WorktreeDeleteProvider`).
## Row mapping (new)
Create adapter rows built from live state (mirror `AgentManagerPanel.WorktreeRow`), not by making the
DTO `HistoryItem` implement `ActiveListItem` (badges/trailing/deleting/cells depend on runtime state:
activity snapshot, title overrides, `controller.deleting`).
New file `session/history/HistoryRows.kt`:
- `internal data class LocalRow(dto: LocalHistoryItem, title: String, kind: SessionActivityKind?, override val deleting: Boolean, section: String?) : ActiveListItem`
- `key = dto.id`; `title` = override title (from snapshot) or `HistoryListUi.title(dto)`.
- `trailing = HistoryTime.relative(dto)` (refreshed by the activity timer rebuild).
- `badges = kind?.let { ActiveListBadge(it.label(), it.style()) }` (empty when `deleting`).
- `section` = `HistoryTime.title(HistoryTime.section(dto))`.
- `search = listOfNotNull(title, dto.id, dto.directory).joinToString(" ")` (only used if search is
ever moved into ActiveList; currently filtering stays in `HistoryModel`).
- `cells = if (deleting) emptyList() else listOf(ActiveListCell(DELETE_CELL, delete-label, icon = AllIcons.Actions.GC, iconOnly = true))`.
- `internal data class CloudRow(dto: CloudHistoryItem, title: String, kind: SessionActivityKind?, section: String?) : ActiveListItem`
- Same as above but **no `cells`** (cloud is not deletable) and `trailing`/`badges`/`section` mapped
identically.
- Pure builder functions (product-meaningful, directly unit-testable — avoids UI introspection):
- `internal fun localRows(items: List<LocalHistoryItem>, snapshot: HistoryActivitySnapshot, deleting: (String) -> Boolean): List<LocalRow>`
- `internal fun cloudRows(items: List<CloudHistoryItem>, snapshot: HistoryActivitySnapshot): List<CloudRow>`
- Both compute `section` via adjacent-equality (only first item of a `HistorySection` bucket gets a
non-null `section`), matching current `HistoryRenderer.section`.
## HistoryPanel changes
1. **Replace the two `HistoryList<T>` (JBList) + renderers with two `ActiveList` instances.**
- `localList = ActiveList(emptyText, cfg = ActiveListConfig(selection = MULTIPLE_INTERVAL_SELECTION, hoverActions = true), surface = ActiveListSurface.Default, showSearch = false, onCell = ..., onOpen = ..., onSelect = ...)`.
- `cloudList = ActiveList(emptyText, cfg = ActiveListConfig(selection = SINGLE_SELECTION), surface = ActiveListSurface.Default, showSearch = false, onOpen = ...)`.
- Keep `panel(search, activeList, footer?)` layout: external `SearchTextField` in NORTH (+ repoOnly
under it for cloud), `activeList` in CENTER, Load-more in SOUTH. `ActiveList` is a
`BorderLayoutPanel`, so it drops into CENTER directly (remove the manual `JBScrollPane` — ActiveList
owns its own scroll).
2. **onCell (local):** `{ key, id -> if (id == DELETE_CELL) showDeletePopup(key) }`.
- `showDeletePopup(key)` builds `ActiveListDeleteOptions(message = history.delete.confirm.message(title))`
and calls `localList.confirmDelete(localList.point(key, DELETE_CELL), opts) { _ -> controller.delete(dto) }`.
Add `controller.requestDelete(1)` / `cancelDelete` telemetry parity as today.
3. **onOpen:** `{ row, _ -> activate((row as LocalRow).dto or (CloudRow).dto) }``controller.open(...)`.
Double-click and Enter route through ActiveList's `onOpen` (drop the manual `MouseAdapter` +
`isDeleteClick` hit-testing and the `registerKeyboardAction` Enter handlers on the list).
4. **Feeding rows:** on `HistoryModel` `ListDataListener` changes (`bind`) and on tab switch, rebuild:
- `localList.update(localRows(controller.local.visibleItems, snapshot, controller::deleting), ActiveListSelection.PreserveNoScroll)`
- `cloudList.update(cloudRows(controller.cloud.visibleItems, snapshot), ActiveListSelection.PreserveNoScroll)`
- Keep `sync()` responsibilities: Load-more `isEnabled/isVisible`, repoOnly visibility, card
`load` vs `tabs`, empty/loading/error text via `activeList` empty text (`setBusy` for loading).
5. **Activity timer (`syncActivity`):** replace selective `repaintRows` with a full rebuild + `update`
(`PreserveNoScroll`) of the affected tab(s). Lists are small; `update` diffs height and preserves
selection/scroll. Update snapshot first, then rebuild only when `snapshot.changed(next)` is
non-empty (keep the existing early-out to avoid churn).
6. **Search wiring:** keep `search(model)` forwarding text to `model.setFilter` (unchanged). Rewire the
up/down/enter keyboard actions on the search editor to ActiveList:
- up/down → `activeList.selectIndex((activeList.selectedIndex() + step).coerceIn(...))`.
- enter → `activeList.selected()?.let(::activate)`.
7. **Loading/empty/error text:** call `activeList.setBusy(model.loading)` and set empty text through a
new `ActiveList` passthrough (see API additions) reflecting loading/error/empty like `syncList`.
8. **DataProvider (`getData` / `HistoryDataKeys.SELECTION`):** read selection from ActiveList:
`localList.selectedItems().map { (it as LocalRow).dto }` and the cloud equivalent. `selectedSource()`
still keyed off the selected tab.
9. **Context menu:** install `Kilo.History.ContextMenu` on the ActiveList's inner list via a new
`ActiveList.installPopup(group)` (see API additions), replacing `PopupHandler.installPopupMenu` on
the old `JBList`.
10. **Delete-element provider / Delete key / RenameElement:** keep existing action wiring; route the
interactive delete path through `showDeletePopup`. Keep `clickDelete()` batch behavior for the
Delete action but confirm via balloon anchored to the first selected row (parity with `confirmDelete`).
11. **Theme (`updateTheme`):** `SwingUtilities.updateComponentTreeUI` on each `ActiveList`
(BorderLayoutPanel) instead of `updateRenderer` reaching into the old renderer.
## ActiveList API additions (small, shared but kilo-owned)
Add to `ui/list/ActiveList.kt` (delegating to `ActiveListView`):
- `fun installPopup(group: ActionGroup)``PopupHandler.installPopupMenu(view.list, group, ActionPlaces.POPUP)`.
(Add matching `ActiveListView.installPopup` or expose `view.list` narrowly. Prefer the delegating
method over widening `preferredFocus()` usage.)
- `fun setEmptyText(text: String)``view.setEmptyText(text)` (method already exists on the view;
just surface it) for loading/error/empty messages.
No changes to `ActiveListRenderer`/hover logic are required — `hoverActions` already implements the
requested reveal.
## Files
- **Edit:** `session/history/HistoryPanel.kt` (major rewiring; keep public constructor signature
`HistoryPanel(parent, controller, nav, manager, timers)` so `SessionSidePanelManager.kt:92` and tests
compile unchanged).
- **New:** `session/history/HistoryRows.kt` (rows + `localRows`/`cloudRows`).
- **Delete:** `session/history/HistoryListRenderer.kt` (`HistoryRenderer`, `Local/CloudHistoryRenderer`,
`BadgeLabel`, `isDeleteClick`, `DELETE_AREA_WIDTH`). `HistoryRenderer.section` logic moves into
`HistoryRows` builders.
- **Edit:** `ui/list/ActiveList.kt` (+ `ActiveListView.kt` if needed) for `installPopup` / `setEmptyText`.
- **Keep unchanged:** `HistoryItem.kt`, `HistoryModel.kt`, `HistoryController.kt`, `HistoryTime.kt`,
`HistoryActivitySnapshot.kt`, `HistoryDataKeys.kt`, `HistoryListUi.kt`.
## Test impact
Existing UI-introspection accessors on `HistoryPanel` used by `HistoryControllerTest.kt` must be
re-pointed at observable state (per plugin AGENTS.md: assert observable UI/behavior, don't add
test-only seams into internals):
- `groupTitles()` → derive from built rows' `section` values (use `localRows`/`cloudRows` +
`activeListSectionTitle`, or expose a small `sections(): List<String>` computed from the current rows).
- `runningBadgeVisible(i)` / `badgeText(i)` / `titleText(i)` → assert against `localRows(...)`/`cloudRows(...)`
output (row `badges`/`title`) directly in unit tests, instead of rendering `HistoryRenderer`.
- `select`, `selectIndices`, `selectedIndex`, `listSelectionMode` → use `ActiveList.selectIndex`/
`selectedIndex`/`selectedItems`; `selectionMode` is asserted via multi-select behavior, not a getter.
- `listFocusable` / `listCursor` / `loadMoreFocusable` → drop or replace with behavior assertions;
ActiveList manages focus/cursor internally.
- `backText`/`backCursor`/`clickBack`, `clickCloud`/`clickLocal`, `clickMore`, `setSearch`,
`repoOnlyVisible`/`repoOnlySelected`/`clickRepoOnly`, `itemCount` → unchanged (shell-level), keep.
New/updated tests (`BasePlatformTestCase`, real EDT, no mocks):
1. `HistoryRowsTest` (pure): `localRows`/`cloudRows` produce expected `title` (override precedence),
`trailing` (relative time), `badges` (activity kind), `section` (adjacent-equality bucketing),
`deleting` clears cells/badges, and local rows carry the delete cell while cloud rows do not.
2. Panel test: local row exposes a `DELETE_CELL`; hovering the selected row reveals it
(`activeListCellBounds` non-empty for the selected+hovered row via the view), clicking it opens the
balloon (`ActiveListDeleteOptions`) and confirming calls `controller.delete`. Cloud rows expose no
delete cell.
3. Panel test: double-click / Enter opens via `controller.open`; selection feeds `HistorySelection`
through `getData`.
4. Keep `HistoryControllerTest` cloud paging, repo-only, activity, and error/empty coverage passing
against the new rendering path.
5. `SessionSidePanelManagerTest` and `HistorySessionActionsTest` continue to pass (constructor + data
provider unchanged).
## Validation
From `packages/kilo-jetbrains/`:
- `./gradlew typecheck`
- `./gradlew test` (or targeted: `HistoryControllerTest`, new `HistoryRowsTest`, `HistorySessionActionsTest`,
`SessionSidePanelManagerTest`).
Manual smoke (monolith sandbox `./gradlew runIde`): open History panel → local rows show relative time;
hover the selected local row → delete icon overlays the time; click → balloon confirm → row shows
"Deleting…" then disappears; cloud tab has no delete; section headers, search, repo-only, Load-more,
and running-activity badges still behave.
## Risks / notes
- `ActiveList.update` reselects/scrolls; always pass `PreserveNoScroll` on timer/model rebuilds to
avoid selection or scroll jumps (matches `AgentManagerPanel.sync`).
- Multi-select local delete: the balloon anchors to a single row; confirm applies to all selected
(parity with existing `confirmDelete(items)`).
- `HistoryPanel` currently exposes many `internal` accessors purely for tests; migration is a good
point to prune the ones that only inspected `JBList` internals rather than adding equivalents on
`ActiveList`.
- Requires an implementation-capable agent (source edits + Gradle); this plan performs no code changes.
@@ -0,0 +1,273 @@
# JetBrains: worktree change badges + PR badges
Add per-worktree git change badges (`+add del`, ahead/behind arrows) and a PR badge
(`#num` + state color) to the JetBrains Agent Manager worktree **list**, and mirror the
same badge into the **worktree editor** session toolbar (rightaligned). Hide the existing
branchchanges badge in the session **header** only for sessions running inside a worktree
editor; keep it for toolwindow sessions.
## Confirmed decisions
- **Fetch model:** ONE batched backend RPC per refresh for git stats (numstat + revlist
ahead/behind for every managed worktree) and a separate batched gh RPC for PRs. A shared
projectlevel frontend service owns the `StateFlow`s and refreshes on discrete triggers
(list load, session turnend/idle, editor selection, tool window becomes visible) with
debounce, plus a slow safety poll (~30s git / ~120s gh) only while a Kilo surface is visible.
Backend caches basebranch resolution and the gh availability probe with a TTL.
- **PR scope:** number + state only (`open`/`draft`/`merged`/`closed`), one `gh pr view` per
worktree. Clicking the PR pill opens the PR URL.
- **gh onboarding:** when gh is missing or unauthenticated, show ONE sticky suggestion
notification per IDE session (install → https://cli.github.com/, auth → open IDE terminal
running `gh auth login`, browse fallback). PR badge stays hidden; git badges still work.
- **Ahead/behind base:** worktree branch upstream `@{upstream}` when set, else the main
worktree's current branch. Diff numstat is computed against `merge-base HEAD <base>`. No
implicit network fetch (counts may be slightly stale, like VS Code).
## Scope / boundaries
- **All changes live in `packages/kilo-jetbrains/` (Kiloowned).** No `kilocode_change`
markers, no `packages/opencode/` edits, no SDK regen, no CLI pin bump. Git/gh run in the
JetBrains backend inprocess (as `KiloWorktreeRpcApiImpl` already does for `worktree list`).
- Modules touched: `shared/` (DTOs + RPC signatures), `backend/` (RPC impl + gh runner),
`frontend/` (status service, list row, editor toolbar, header flag, notifications, i18n).
- No new plugin.xml/moduleXML wiring is required if the new frontend service is a light
`@Service(Service.Level.PROJECT)`. The `KiloWorktreeRpcApi` provider is already registered.
## Data flow
```
Frontend triggers ─▶ WorktreeStatusService (project light service)
(list load / turn-end / editor select / visible + slow poll, debounced)
│ suspend RPC (batched, off-EDT, durable{})
KiloWorktreeRpcApi.stats(dir) ── backend runGit per worktree (bounded concurrency)
KiloWorktreeRpcApi.prStatus(dir) ── backend runGh per worktree (probe-gated, TTL cache)
│ StateFlow<Map<path, WorktreeStatsDto>> / <Map<path, WorktreePrDto>> / GhAvailability
┌───────────────────────────┬──────────────────────────────┐
│ AgentManagerPanel (list) │ WorktreeSessionEditorPanel │
│ trailing stats per row │ toolbar EAST stats badge │
└───────────────────────────┴──────────────────────────────┘
```
## Task 1 — Shared DTOs (`shared/.../rpc/dto/WorktreeDto.kt`)
Add `@Serializable` payloads (singleword fields, nullablesafe defaults):
- `WorktreeStatsDto(path, additions=0, deletions=0, ahead=0, behind=0)`
- `WorktreeStatsListDto(items: List<WorktreeStatsDto> = emptyList())`
- `enum GhState { OPEN, DRAFT, MERGED, CLOSED }`
- `WorktreePrDto(path, number, state: GhState, url, )`
- `enum GhAvailability { OK, MISSING, UNAUTH }`
- `WorktreePrListDto(availability: GhAvailability = GhAvailability.OK, items: List<WorktreePrDto> = emptyList())`
Do **not** add stat fields to `WorktreeDto` itself — keep list identity separate from
volatile stats so list reloads and stat refreshes don't fight.
## Task 2 — Shared RPC signatures (`shared/.../rpc/KiloWorktreeRpcApi.kt`)
Add two suspend methods to the existing `@Rpc interface KiloWorktreeRpcApi`:
- `suspend fun stats(directory: String): WorktreeStatsListDto`
- `suspend fun prStatus(directory: String): WorktreePrListDto`
## Task 3 — Backend git/gh (`backend/.../rpc/KiloWorktreeRpcApiImpl.kt`)
Reuse `managedWorktrees(parseWorktreeList(...))` to enumerate nonmain worktrees, then:
**`stats(directory)`** — for each managed nonmain worktree, run git with `cwd = worktree.path`
(via a small variant of `runGit` that accepts the worktree dir), in parallel with bounded
concurrency (e.g. `Semaphore(4)` / `coroutineScope { map { async {...} } }`), all under
`Dispatchers.IO`:
- Resolve `base`: `git rev-parse --abbrev-ref --symbolic-full-name @{upstream}` → use it if
exit 0; else the main worktree branch (from the alreadyparsed main entry). Cache resolution
per worktree path with a short TTL (~60s) to avoid reresolving every poll.
- `ancestor = git merge-base HEAD <base>` (fall back to `base` on failure).
- Diff: `git -c core.quotepath=false diff --numstat --no-renames <ancestor>` → sum col1/col2
as additions/deletions (skip `-` binary rows). Add untracked line counts via
`git ls-files --others --exclude-standard` capped like `WorktreeDiff` (mirror
`packages/opencode/src/kilocode/review/worktree-diff.ts` / VS Code `local-diff.ts`).
- Ahead/behind: `git rev-list --left-right --count <base>...HEAD``behind ahead`.
- Any perworktree git failure ⇒ zeros for that worktree (never throw).
**`prStatus(directory)`** — add `runGh(base, vararg args)` analogous to `runGit` but built
with `GeneralCommandLine(listOf("gh")+args).withWorkDirectory(...)
.withParentEnvironmentType(ParentEnvironmentType.CONSOLE)` so the loginshell PATH from
`EnvironmentUtil` is used (fixes GUIlaunched IDE not seeing Homebrew `gh`; mirrors VS Code
`shell-env.ts`). Then:
- Probe: `gh --version` (cache result + timestamp ~5min TTL on the impl). ENOENT / "not
recognized" ⇒ `MISSING`.
- If missing ⇒ return `WorktreePrListDto(MISSING)` immediately (no perworktree calls).
- For each worktree (bounded concurrency, cwd = worktree path):
`gh pr view <branch> --json number,state,isDraft,url` (branch = worktree branch). Map
`state`+`isDraft``GhState`. "no pull requests found" ⇒ omit that worktree. stderr
containing "not logged"/"gh auth login" ⇒ mark availability `UNAUTH` and stop (return
`UNAUTH` with whatever succeeded).
- Cache the `WorktreePrListDto` per directory with a TTL (~60120s) so the slow frontend poll
and event triggers coalesce.
- Reuse the error classification shape from VS Code `git-import.ts:classifyPRError`
(missing / auth / not_found).
Keep `runGit`'s 30s timeout; give gh a similar bounded timeout.
## Task 4 — Frontend service `WorktreeStatusService` (project light `@Service`)
New file `frontend/.../agentManager/worktree/WorktreeStatusService.kt`:
- Injected `(Project, CoroutineScope)`. Holds:
- `stats: StateFlow<Map<String, WorktreeStatsDto>>` (keyed by normalized path)
- `pr: StateFlow<Map<String, WorktreePrDto>>`
- `gh: StateFlow<GhAvailability>`
- `refreshStats()` — debounced (~300ms), calls `KiloWorktreeService.stats(dir)` offEDT in
`durable { }`, updates the flow. Safe fallback: on failure keep last value + log.
- `refreshPr()` — throttled (min interval ~30s between real calls), calls
`KiloWorktreeService.prStatus(dir)`; updates `pr` + `gh`; triggers the onboarding
notification (Task 8) when availability becomes `MISSING`/`UNAUTH`.
- **Visibility gating + safety poll:** track subscriber/attach count; run the slow polls
(~30s stats / ~120s pr) only while at least one subscriber (list or editor toolbar) is
showing. Use `UiTimers`/service scope, cancel when hidden.
- `directory` = project base path (the repo root that owns `.kilo/worktrees`).
Extend `KiloWorktreeService` (`frontend/.../worktree/KiloWorktreeService.kt`) with `stats` /
`prStatus` wrappers following the existing `call { }` + try/catch → safedefault pattern.
## Task 5 — Reusable badge view `WorktreeStatsView`
New retained Swing component `frontend/.../agentManager/worktree/WorktreeStatsView.kt`
(built once, `update(stats, pr)` mutates children — obey the retainedcomponent rules):
- Horizontal `Stack`: optional behind (`↓N`) + ahead (`↑N`) fragments (reuse header
`/icons/arrow-down-to-line.svg` and `/icons/arrow-up.svg`; hide when 0), reuse
`DiffStatBadge(Variant.COMPACT)` for `+add del`, and an optional PR pill.
- PR pill: a `JBLabel` whose icon is a `FilledBadgeIcon("#$number", style)` where the style
maps `GhState` to a `UiStyle.Badge` variant (open→Primary/Highlight, draft→Secondary,
merged→a purpleish/Highlight, closed→Alert). Clicking opens `pr.url` via `BrowserUtil`.
- Colors from theme APIs only (`UiStyle.Colors.addedForeground()/removedForeground()`,
`UIUtil`/`JBUI.CurrentTheme`); no literals.
## Task 6 — Worktree list rows (`AgentManagerPanel.kt` + `ActiveListRenderer`)
The list renderer today only supports text pills (`badges`) and a single trailing text
(`trail`). Add a rich trailing metric slot:
- In `ui/list/ActiveListModel.kt`: add `data class ActiveListMetrics(additions, deletions,
ahead, behind, pr: ActiveListBadge?)` and `val metrics: ActiveListMetrics? get() = null` on
`ActiveListItem`.
- In `ui/list/ActiveListRenderer.kt`: hold ONE retained `WorktreeStatsView`style component in
the trailing area (mutually exclusive with `trail` text — metrics win). Update it per row in
`getListCellRendererComponent` (single retained instance, no perrow allocation), matching
the existing `syncBadges`/`syncCells` reuse pattern. Ensure `activeListCellBounds` hittesting
is unaffected (metrics area is noninteractive in the list; the row `onOpen` still opens the
editor — PR click in the list is optional, keep click handling in the editor toolbar only for
v1 to avoid list hittest complexity).
- In `AgentManagerPanel`: subscribe to `WorktreeStatusService` in `init`, store latest
stats/pr, and populate `WorktreeRow.metrics` in `sync()`. Trigger `refreshStats()` from
`refresh()`/`reload` and on `onActivityChanged`; trigger `refreshPr()` on list load + slow
poll. Register the panel as a status subscriber (drives visibility gating).
## Task 7 — Worktree editor toolbar (`WorktreeSessionEditorPanel.kt`)
- In `toolbar()` (currently only fills `BorderLayout.WEST`), add a `WorktreeStatsView` at
`BorderLayout.EAST` of the bottombordered panel.
- Subscribe to `WorktreeStatusService` filtered by `worktree.directory`; `update()` the view on
flow changes. Trigger `refreshStats()`/`refreshPr()` when the editor becomes visible
(`addHierarchyListener`/`start()`) and on `manager` activity changes; register as a status
subscriber for visibility gating.
- Clicking the toolbar PR pill opens the PR URL; clicking the diff stat opens the branch diff
editor for this worktree (reuse the same `KiloDiffEditorKind` "branch" open used by
`SessionUi.openBranchDiff`; extract a small shared helper or duplicate the few lines).
## Task 8 — Hide header badge in worktree editor sessions
- Add `val showsBranchBadgeInHeader: Boolean get() = true` to the `SessionManager` interface
(`session/SessionManager.kt`).
- Override to `false` in `WorktreeSessionEditorManager`.
- In `SessionUi`: guard `refreshBranchChanges()` (and the header badge wiring /
`openBranchChanges` from the header) with `manager?.showsBranchBadgeInHeader != false`.
When false: never call `header.setBranchChanges(...)` and skip the persession
`branchDiff` fetch entirely (performance win — the shared service already computes it).
`SessionSidePanelManager` (tool window) keeps the default `true`, so its header badge is
unchanged.
## Task 9 — gh onboarding notification
- Add a suggestion helper (extend `KiloNotifications` or a small local object) that posts a
single suggestion notification in the existing `"Kilo Code"` group with
`setSuggestionType(true)`; consider a dedicated `STICKY_BALLOON` notificationGroup in
`kilo.jetbrains.frontend.xml` if stickiness is required.
- `WorktreeStatusService` calls it at most once per IDE session (guard with a flag) when
`gh` flips to `MISSING` or `UNAUTH`:
- `MISSING` → "GitHub CLI not found" + action **Install** → `BrowserUtil.browse("https://cli.github.com/")`.
- `UNAUTH` → "GitHub CLI not authorized" + action **Authorize** → open the IDE terminal
running `gh auth login` when the Terminal plugin is available
(`org.jetbrains.plugins.terminal.TerminalToolWindowManager`), else browse the auth docs.
- Include an expiring **Don't show again** dismiss.
- Fallback is always safe: PR badges simply stay absent; git badges continue to work.
## Task 10 — i18n
Add keys to the base bundle `frontend/.../resources/messages/KiloBundle.properties` (other
locales fall back to English): stat tooltips (ahead/behind/added/deleted), PR pill tooltip,
gh notification titles/bodies/action labels. Use `KiloBundle.message(...)` everywhere; build
HTML (if any) with `HtmlChunk`/`XmlStringUtil`.
## Performance summary
- One batched `stats` RPC and one batched `prStatus` RPC per refresh, not per row.
- Backend runs git/gh per worktree with bounded concurrency; base resolution + gh probe +
pr results are TTLcached.
- Frontend refresh is eventdriven (list load, turnend, editor select, toolwindow visible),
debounced, with a slow safety poll that runs **only while a Kilo surface is visible**.
- gh shortcircuits to no perworktree calls when the probe says `MISSING`.
- Worktree editor sessions no longer issue their own persession `branchDiff` fetch.
## Failure modes / fallback
- git subprocess failure per worktree → zeros for that worktree; never throws; row still renders.
- gh missing → `MISSING`, badges hidden, one onboarding suggestion.
- gh unauthenticated → `UNAUTH`, badges hidden, one onboarding suggestion.
- No PR for a branch → PR pill absent; git badges still shown.
- Backend/RPC error → frontend keeps last known values and logs via `log.error(..., err)`.
## Testing
- **Backend** (mirror `backend/.../rpc/KiloWorktreeRpcApiImplTest` / `BranchDiffTest`, real
temp git repos, no mocks): `stats` numstat + revlist ahead/behind against a seeded
worktree; untracked line counting; base resolution upstreamvsmain fallback; `prStatus`
ghmissing classification (fake `gh` on PATH or a `runGh` seam) → `MISSING`, and stderr
authfailure → `UNAUTH`; perworktree failure yields zeros.
- **Frontend** (`BasePlatformTestCase`, real EDT, fake RPC): `WorktreeStatusService` maps flow
→ row metrics and toolbar view; `AgentManagerPanelTest` renders diff/aheadbehind/PR pill;
`WorktreeSessionEditorPanelTest` shows the toolbar badge; retainedcomponent tests for
`WorktreeStatsView` (`update()` mutates without rebuilding, noop updates don't repaint);
`ActiveListRenderer` metric reuse.
- **Header flag**: a `SessionControllerTestBase`/`SessionUi` test asserting the branch badge
is hidden and no `branchDiff` fetch occurs when `manager.showsBranchBadgeInHeader == false`,
and still shown for the default toolwindow manager.
- **Notification**: assert the suggestion fires once per session for `MISSING`/`UNAUTH` and not
when `OK`.
## Validation commands (from `packages/kilo-jetbrains/`)
- `./gradlew typecheck`
- `./gradlew test` (or targeted `--tests` for the new/updated classes)
- Manual: `./gradlew --no-configuration-cache runIdeSplitMode` (or `runIde`), open Agent
Manager with ≥1 worktree that has changes and a PR; verify list badges, editor toolbar badge
rightaligned, header badge hidden in the worktree editor but present in the sidebar, and the
gh onboarding notification when `gh` is absent/unauthenticated.
## Risks / notes
- `WorktreeStatsView` in the list must obey retainedSwing reuse (one instance mutated per row)
or it will thrash the renderer — call out in review.
- `gh auth login` in the IDE terminal depends on the optional Terminal plugin; guard the lookup
and fall back to browsing docs so a missing terminal never breaks the action.
- Ahead/behind uses local refs only (no fetch); numbers can lag the remote, matching VS Code —
acceptable and documented in tooltips.
- `runGh` must use `ParentEnvironmentType.CONSOLE` (loginshell PATH) or GUIlaunched IDEs on
macOS won't find Homebrew `gh`.
## Out of scope (v1)
- gh checks / review decision / unresolvedcomment counts (VS Code full parity).
- Stats/PR badges for the main (sidebar) workspace.
- Clickable PR pill inside the list rows (kept in the editor toolbar only for v1).
- Any `packages/opencode/` server endpoint, SDK, or CLIpin work.
@@ -4,31 +4,57 @@ import ai.kilocode.log.KiloLog
import ai.kilocode.rpc.KiloWorktreeRpcApi
import ai.kilocode.rpc.dto.CreateWorktreeRequestDto
import ai.kilocode.rpc.dto.CreateWorktreeResultDto
import ai.kilocode.rpc.dto.GhAvailability
import ai.kilocode.rpc.dto.GhState
import ai.kilocode.rpc.dto.RemoveWorktreeResultDto
import ai.kilocode.rpc.dto.RenameWorktreeResultDto
import ai.kilocode.rpc.dto.WorktreeBranchesDto
import ai.kilocode.rpc.dto.WorktreeDto
import ai.kilocode.rpc.dto.WorktreeListDto
import ai.kilocode.rpc.dto.WorktreePrDto
import ai.kilocode.rpc.dto.WorktreePrListDto
import ai.kilocode.rpc.dto.WorktreeStatsDto
import ai.kilocode.rpc.dto.WorktreeStatsListDto
import com.intellij.execution.configurations.GeneralCommandLine
import com.intellij.execution.configurations.GeneralCommandLine.ParentEnvironmentType
import com.intellij.execution.process.CapturingProcessHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.MapSerializer
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonPrimitive
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardCopyOption
import java.util.concurrent.ConcurrentHashMap
import kotlin.io.path.fileSize
import kotlin.io.path.inputStream
import kotlin.io.path.isRegularFile
class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi {
companion object {
internal val LOG = KiloLog.create(KiloWorktreeRpcApiImpl::class.java)
private const val BASE_TTL = 60_000L
private const val GH_PROBE_TTL = 300_000L
private const val PR_TTL = 90_000L
}
private val bases = ConcurrentHashMap<String, Timed<String>>()
private val prs = ConcurrentHashMap<String, Timed<WorktreePrListDto>>()
@Volatile
private var ghProbe: Timed<GhAvailability>? = null
override suspend fun list(directory: String): WorktreeListDto = withContext(Dispatchers.IO) {
val base = Path.of(directory).normalize()
val res = runGit(base, "worktree", "list", "--porcelain")
@@ -48,6 +74,44 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi {
WorktreeBranchesDto(branches, current)
}
override suspend fun stats(directory: String): WorktreeStatsListDto = withContext(Dispatchers.IO) {
val root = Path.of(directory).normalize()
val res = runGit(root, "worktree", "list", "--porcelain")
if (!res.ok) return@withContext WorktreeStatsListDto()
val items = managedWorktrees(parseWorktreeList(res.stdout))
val main = items.firstOrNull { it.main }
val fallback = main?.branch?.takeIf { it.isNotBlank() && it != "(detached)" } ?: "HEAD"
WorktreeStatsListDto(parallel(items.filter { !it.main }) { item -> stats(item, fallback) })
}
override suspend fun prStatus(directory: String): WorktreePrListDto = withContext(Dispatchers.IO) {
val now = System.currentTimeMillis()
prs[directory]?.takeIf { now - it.time < PR_TTL }?.let { return@withContext it.value }
val root = Path.of(directory).normalize()
val available = ghAvailable(root)
if (available != GhAvailability.OK) return@withContext WorktreePrListDto(available).also { prs[directory] = Timed(now, it) }
val res = runGit(root, "worktree", "list", "--porcelain")
if (!res.ok) return@withContext WorktreePrListDto().also { prs[directory] = Timed(now, it) }
val items = managedWorktrees(parseWorktreeList(res.stdout)).filter { !it.main && it.branch != "(detached)" }
var status = GhAvailability.OK
val data = parallel(items) { item ->
if (status != GhAvailability.OK) return@parallel null
val out = runGh(Path.of(item.path).normalize(), "pr", "view", item.branch, "--json", "number,state,isDraft,url")
if (!out.ok) {
when (prError(out.stderr)) {
GhAvailability.UNAUTH -> status = GhAvailability.UNAUTH
GhAvailability.MISSING -> status = GhAvailability.MISSING
GhAvailability.OK -> Unit
}
return@parallel null
}
parsePr(item.path, out.stdout)
}.filterNotNull()
val dto = WorktreePrListDto(status, if (status == GhAvailability.OK) data else emptyList())
prs[directory] = Timed(System.currentTimeMillis(), dto)
dto
}
override suspend fun create(directory: String, request: CreateWorktreeRequestDto): CreateWorktreeResultDto =
withContext(Dispatchers.IO) {
val base = Path.of(directory).normalize()
@@ -166,6 +230,8 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi {
val ok get() = exit == 0
}
private data class Timed<T>(val time: Long, val value: T)
private fun runGit(base: Path, vararg args: String): GitResult {
return try {
val cmd = GeneralCommandLine(listOf("git") + args).withWorkDirectory(base.toFile())
@@ -175,6 +241,95 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi {
GitResult(-1, "", e.message ?: "git failed")
}
}
private fun runGh(base: Path, vararg args: String): GitResult {
return try {
val cmd = GeneralCommandLine(listOf("gh") + args)
.withWorkDirectory(base.toFile())
.withParentEnvironmentType(ParentEnvironmentType.CONSOLE)
val out = CapturingProcessHandler(cmd).runProcess(30_000)
GitResult(if (out.isTimeout) -1 else out.exitCode, out.stdout, out.stderr)
} catch (e: Exception) {
GitResult(-1, "", e.message ?: "gh failed")
}
}
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() }
}
private fun stats(item: WorktreeDto, fallback: String): WorktreeStatsDto {
val dir = Path.of(item.path).normalize()
return runCatching {
val base = base(item, fallback)
val anc = runGit(dir, "merge-base", "HEAD", base).stdout.trim().takeIf { it.isNotBlank() } ?: base
val diff = runGit(dir, "-c", "core.quotepath=false", "diff", "--numstat", "--no-renames", anc)
val tracked = if (diff.ok) parseNumstat(diff.stdout) else emptyList()
val untracked = runGit(dir, "-c", "core.quotepath=false", "ls-files", "--others", "--exclude-standard")
.stdout
.lineSequence()
.filter { it.isNotBlank() }
.sumOf { countUntracked(dir, it) }
val counts = aheadBehind(dir, base)
WorktreeStatsDto(
item.path,
tracked.sumOf { it.additions } + untracked,
tracked.sumOf { it.deletions },
counts.second,
counts.first,
)
}.getOrElse { err ->
LOG.warn("worktree stats failed: path=${item.path} message=${err.message}", err)
WorktreeStatsDto(item.path)
}
}
private fun base(item: WorktreeDto, fallback: String): String {
val now = System.currentTimeMillis()
bases[item.path]?.takeIf { now - it.time < BASE_TTL }?.let { return it.value }
val dir = Path.of(item.path).normalize()
val upstream = runGit(dir, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}")
val value = upstream.stdout.trim().takeIf { upstream.ok && it.isNotBlank() } ?: fallback
bases[item.path] = Timed(now, value)
return value
}
private fun aheadBehind(dir: Path, base: String): Pair<Int, Int> {
val out = runGit(dir, "rev-list", "--left-right", "--count", "$base...HEAD")
if (!out.ok) return 0 to 0
val parts = out.stdout.trim().split(Regex("\\s+"))
return (parts.getOrNull(0)?.toIntOrNull() ?: 0) to (parts.getOrNull(1)?.toIntOrNull() ?: 0)
}
private fun ghAvailable(root: Path): GhAvailability {
val now = System.currentTimeMillis()
ghProbe?.takeIf { now - it.time < GH_PROBE_TTL }?.let { return it.value }
val res = runGh(root, "--version")
val value = if (res.ok) GhAvailability.OK else GhAvailability.MISSING
ghProbe = Timed(now, value)
return value
}
private fun prError(stderr: String): GhAvailability {
val text = stderr.lowercase()
if (text.contains("not logged") || text.contains("gh auth login") || text.contains("authentication")) return GhAvailability.UNAUTH
if (text.contains("not found") || text.contains("no pull requests found")) return GhAvailability.OK
return GhAvailability.OK
}
private fun parsePr(path: String, raw: String): WorktreePrDto? {
val obj = runCatching { json.parseToJsonElement(raw) as? JsonObject }.getOrNull() ?: return null
val number = obj["number"]?.jsonPrimitive?.intOrNull ?: return null
val url = obj["url"]?.jsonPrimitive?.content?.takeIf { it.isNotBlank() } ?: return null
val draft = obj["isDraft"]?.jsonPrimitive?.booleanOrNull == true
val state = if (draft) GhState.DRAFT else when (obj["state"]?.jsonPrimitive?.content?.uppercase()) {
"MERGED" -> GhState.MERGED
"CLOSED" -> GhState.CLOSED
else -> GhState.OPEN
}
return WorktreePrDto(path, number, state, url)
}
}
private val json = Json { prettyPrint = true; ignoreUnknownKeys = true }
@@ -351,3 +506,36 @@ private fun realPath(path: String): Path {
val file = Path.of(path).normalize()
return if (Files.exists(file)) file.toRealPath() else file
}
private fun countUntracked(base: Path, rel: String): Int {
return runCatching {
val path = base.resolve(rel).normalize()
if (!path.startsWith(base) || !path.isRegularFile() || path.fileSize() > 2 * 1024 * 1024L) return@runCatching 0
countLines(path) ?: 0
}.getOrElse { err ->
KiloWorktreeRpcApiImpl.LOG.debug { "worktree stats untracked read failed: path=$rel message=${err.message}" }
0
}
}
private fun countLines(path: Path): Int? {
var newlines = 0
var last = 0
var any = false
path.inputStream().buffered().use { input ->
val buf = ByteArray(8192)
while (true) {
val n = input.read(buf)
if (n <= 0) break
any = true
for (i in 0 until n) {
val b = buf[i].toInt()
if (b == 0) return null
if (b == '\n'.code) newlines++
}
last = buf[n - 1].toInt()
}
}
if (!any) return 0
return if (last == '\n'.code) newlines else newlines + 1
}
@@ -319,6 +319,24 @@ class KiloWorktreeRpcApiImplTest {
assertTrue(result.branches.contains(result.current), "current should be among branches")
}
@Test
fun `stats reports managed worktree diff and ahead counts`() = runBlocking {
initRepo()
val created = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("feature/x")).worktree)
val dir = Path.of(created.path)
Files.writeString(dir.resolve("tracked.txt"), "one\n")
git(dir, "add", "tracked.txt")
git(dir, "commit", "-m", "feature")
Files.writeString(dir.resolve("notes.txt"), "two\nthree\n")
val item = api.stats(repo.toString()).items.single { it.path == created.path }
assertEquals(3, item.additions)
assertEquals(0, item.deletions)
assertEquals(1, item.ahead)
assertEquals(0, item.behind)
}
private fun initRepo() {
git(repo, "init")
git(repo, "config", "user.email", "test@kilo.ai")
@@ -1,5 +1,6 @@
package ai.kilocode.client
import ai.kilocode.client.plugin.KiloBundle
import com.intellij.notification.Notification
import com.intellij.notification.NotificationAction
import com.intellij.notification.NotificationGroupManager
@@ -41,4 +42,15 @@ object KiloNotifications {
?: Notification(GROUP, title, content ?: "", NotificationType.INFORMATION)
notification.notify(project)
}
fun suggestion(project: Project?, title: String, content: String?, actionLabel: String, action: () -> Unit) {
val notification = NotificationGroupManager.getInstance()
.getNotificationGroup(GROUP)
?.createNotification(title, content ?: "", NotificationType.INFORMATION)
?: Notification(GROUP, title, content ?: "", NotificationType.INFORMATION)
notification.setSuggestionType(true)
notification.addAction(NotificationAction.createSimpleExpiring(actionLabel) { action() })
notification.addAction(NotificationAction.createSimpleExpiring(KiloBundle.message("common.dont.show.again")) {})
notification.notify(project)
}
}
@@ -4,11 +4,14 @@ import ai.kilocode.client.KiloNotifications
import ai.kilocode.client.agentManager.worktree.ConfigureWorktreeDialog
import ai.kilocode.client.agentManager.worktree.WorktreeController
import ai.kilocode.client.agentManager.worktree.WorktreeIcons
import ai.kilocode.client.agentManager.worktree.WorktreeStatusService
import ai.kilocode.client.agentManager.worktree.WorktreeNameCache
import ai.kilocode.client.agentManager.worktree.WorktreeEditorMatchers
import ai.kilocode.client.agentManager.worktree.WorktreeSessionEditorMatcher
import ai.kilocode.client.agentManager.worktree.WorktreeSessionEditorKind
import ai.kilocode.client.agentManager.worktree.ensureWorktreeSessionEditorKind
import ai.kilocode.client.agentManager.worktree.normalizeWorktreePath
import ai.kilocode.client.agentManager.worktree.style
import ai.kilocode.client.agentManager.worktree.worktreeActivityBadge
import ai.kilocode.client.agentManager.worktree.worktreeSessionParams
import ai.kilocode.client.plugin.KiloBundle
@@ -22,6 +25,7 @@ import ai.kilocode.client.ui.list.ActiveListCell
import ai.kilocode.client.ui.list.ActiveListConfig
import ai.kilocode.client.ui.list.ActiveListDeleteOptions
import ai.kilocode.client.ui.list.ActiveListItem
import ai.kilocode.client.ui.list.ActiveListMetrics
import ai.kilocode.client.ui.list.ActiveListSelection
import ai.kilocode.client.ui.list.ActiveListSurface
import ai.kilocode.client.ui.list.activeListDeleteCell
@@ -30,6 +34,8 @@ import ai.kilocode.client.ui.list.activeListToolWindowBackground
import ai.kilocode.client.vfs.KiloVfsManager
import ai.kilocode.rpc.dto.RemoveWorktreeResultDto
import ai.kilocode.rpc.dto.WorktreeDto
import ai.kilocode.rpc.dto.WorktreePrDto
import ai.kilocode.rpc.dto.WorktreeStatsDto
import com.intellij.icons.AllIcons
import com.intellij.ide.DeleteProvider
import com.intellij.ide.ui.LafManagerListener
@@ -58,6 +64,12 @@ import javax.swing.event.ListDataEvent
import javax.swing.event.ListDataListener
import javax.swing.JComponent
import javax.swing.SwingUtilities
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
/**
* Agent Manager panel: a git-worktree list with search and a delete action revealed on selection,
@@ -87,6 +99,10 @@ class AgentManagerPanel(
onSelect = { selectedRow()?.dto?.id?.let { selected = it } },
)
private var selected: String? = null
private val cs = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private var stats: Map<String, WorktreeStatsDto> = emptyMap()
private var prs: Map<String, WorktreePrDto> = emptyMap()
private var status: AutoCloseable? = null
init {
Disposer.register(parent, this)
@@ -104,7 +120,11 @@ class AgentManagerPanel(
}
controller.onCreateFailure = { err -> notifyCreateFailed(err) }
controller.onRemoveSuccess = { item, index -> onRemoved(item, index) }
controller.onActivityChanged = { sync() }
controller.onActivityChanged = {
sync()
project?.service<WorktreeStatusService>()?.refreshStats()
}
bindStatus()
bindEditorSelection()
// Reflect names adopted or renamed in a worktree session editor tab in the list live.
service<WorktreeNameCache>().addListener(this) { path, name -> controller.applyName(path, name) }
@@ -120,6 +140,8 @@ class AgentManagerPanel(
fun refresh() {
selected = currentEditorWorktree()
controller.reload()
project?.service<WorktreeStatusService>()?.refreshStats()
project?.service<WorktreeStatusService>()?.refreshPr()
}
/** Branch shown in the quick "New Worktree from …" menu item. */
@@ -293,7 +315,8 @@ class AgentManagerPanel(
list.update(
(0 until controller.model.size).map {
val item = controller.model.getElementAt(it)
WorktreeRow(item, controller.isPending(item.id), controller.isDeleting(item.id), controller.kind(item.path))
val key = normalizeWorktreePath(item.path)
WorktreeRow(item, controller.isPending(item.id), controller.isDeleting(item.id), controller.kind(item.path), stats[key], prs[key])
},
ActiveListSelection.PreserveNoScroll,
)
@@ -333,11 +356,43 @@ class AgentManagerPanel(
.firstOrNull { it.id == key }
}
private fun bindStatus() {
val target = project ?: return
val service = target.service<WorktreeStatusService>()
status = service.attach()
service.refreshStats()
service.refreshPr()
cs.launch {
service.stats.collectLatest { value ->
edtIfAlive {
stats = value
sync()
}
}
}
cs.launch {
service.pr.collectLatest { value ->
edtIfAlive {
prs = value
sync()
}
}
}
}
private fun edtIfAlive(block: () -> Unit) {
ApplicationManager.getApplication().invokeLater {
if ((project == null || !project.isDisposed) && !Disposer.isDisposed(this)) block()
}
}
override fun dispose() {
controller.onSelect = null
controller.onCreateFailure = null
controller.onRemoveSuccess = null
controller.onActivityChanged = null
status?.close()
cs.cancel()
}
override fun uiDataSnapshot(sink: DataSink) {
@@ -382,6 +437,8 @@ class AgentManagerPanel(
val pending: Boolean,
override val deleting: Boolean,
val kind: SessionActivityKind?,
val stats: WorktreeStatsDto?,
val pr: WorktreePrDto?,
) : ActiveListItem {
override val key: String get() = dto.id
override val title: String get() = dto.name
@@ -394,6 +451,20 @@ class AgentManagerPanel(
if (pending || deleting) return emptyList()
return listOfNotNull(kind?.let(::worktreeActivityBadge))
}
override val metrics: ActiveListMetrics?
get() {
if (pending || deleting) return null
val s = stats
val p = pr
if (s == null && p == null) return null
return ActiveListMetrics(
additions = s?.additions ?: 0,
deletions = s?.deletions ?: 0,
ahead = s?.ahead ?: 0,
behind = s?.behind ?: 0,
pr = p?.let { ActiveListBadge("#${it.number}", style(it.state)) },
)
}
override val cells: List<ActiveListCell>
get() = if (dto.main || pending) emptyList() else listOf(
activeListRenameCell(KiloBundle.message("worktree.rename.action")),
@@ -10,6 +10,8 @@ import ai.kilocode.rpc.dto.RemoveWorktreeResultDto
import ai.kilocode.rpc.dto.RenameWorktreeResultDto
import ai.kilocode.rpc.dto.WorktreeBranchesDto
import ai.kilocode.rpc.dto.WorktreeListDto
import ai.kilocode.rpc.dto.WorktreePrListDto
import ai.kilocode.rpc.dto.WorktreeStatsListDto
import com.intellij.openapi.components.Service
import fleet.rpc.client.durable
import kotlinx.coroutines.CoroutineScope
@@ -50,6 +52,20 @@ class KiloWorktreeService internal constructor(
WorktreeBranchesDto()
}
suspend fun stats(directory: String): WorktreeStatsListDto = try {
call { stats(directory) }
} catch (e: Exception) {
LOG.warn("worktree stats failed for $directory", e)
WorktreeStatsListDto()
}
suspend fun prStatus(directory: String): WorktreePrListDto = try {
call { prStatus(directory) }
} catch (e: Exception) {
LOG.warn("worktree PR status failed for $directory", e)
WorktreePrListDto()
}
suspend fun create(directory: String, req: CreateWorktreeRequestDto): CreateWorktreeResultDto =
call { create(directory, req) }
@@ -43,7 +43,7 @@ object WorktreeSessionEditorKind : KiloEditorKind {
Disposer.register(parent) { cs.cancel() }
val controller = WorktreeSessionListController(project.service<KiloSessionService>(), path, cs)
val manager = WorktreeSessionEditorManager(parent, project, worktree, controller)
return WorktreeSessionEditorPanel(parent, manager, controller, worktree)
return WorktreeSessionEditorPanel(parent, manager, controller, worktree, project)
}
private fun name(path: String): String {
@@ -63,6 +63,7 @@ open class WorktreeSessionEditorManager(
}
},
) : SessionHost(project, worktree, create, resolve, status, timers, request) {
override val showsBranchBadgeInHeader: Boolean get() = false
private val right = JPanel(BorderLayout())
private val deleting = linkedSetOf<String>()
private var last: String? = null
@@ -1,6 +1,9 @@
package ai.kilocode.client.agentManager.worktree
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.diff.KiloDiffEditorKind
import ai.kilocode.client.diff.diffParams
import ai.kilocode.client.diff.ensureDiffEditorKind
import ai.kilocode.client.session.SessionActivityKind
import ai.kilocode.client.session.SessionHost
import ai.kilocode.client.session.SessionManager
@@ -23,7 +26,10 @@ import ai.kilocode.client.ui.list.ActiveListSurface
import ai.kilocode.client.ui.list.activeListDeleteCell
import ai.kilocode.client.ui.list.activeListRenameCell
import ai.kilocode.client.ui.list.activeListToolWindowBackground
import ai.kilocode.client.vfs.KiloVfsManager
import ai.kilocode.rpc.dto.SessionDto
import ai.kilocode.rpc.dto.WorktreePrDto
import ai.kilocode.rpc.dto.WorktreeStatsDto
import com.intellij.icons.AllIcons
import com.intellij.ide.ui.LafManagerListener
import com.intellij.openapi.Disposable
@@ -36,6 +42,8 @@ import com.intellij.openapi.actionSystem.DataSink
import com.intellij.openapi.actionSystem.DefaultActionGroup
import com.intellij.openapi.actionSystem.UiDataProvider
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.Disposer
import com.intellij.ui.IdeBorderFactory
import com.intellij.ui.OnePixelSplitter
@@ -52,12 +60,19 @@ import javax.swing.JPanel
import javax.swing.SwingUtilities
import javax.swing.event.ListDataEvent
import javax.swing.event.ListDataListener
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
class WorktreeSessionEditorPanel(
parent: Disposable,
private val manager: WorktreeSessionEditorManager,
private val controller: WorktreeSessionListController,
private val worktree: ai.kilocode.client.app.Workspace,
private val project: Project? = null,
private val confirm: ((RelativePoint, ActiveListDeleteOptions, () -> Unit) -> Unit)? = null,
private val edit: ((RelativePoint, ActiveListEditOptions, (String) -> Unit) -> Unit)? = null,
) : BorderLayoutPanel(), Disposable, UiDataProvider {
@@ -81,7 +96,12 @@ class WorktreeSessionEditorPanel(
},
onOpen = { row, focus -> open(row, focus) },
)
private val statsView = WorktreeStatsView(::openBranchDiff)
private var started = false
private val cs = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private var status: AutoCloseable? = null
private var stats: WorktreeStatsDto? = null
private var pr: WorktreePrDto? = null
init {
Disposer.register(parent, this)
@@ -98,13 +118,21 @@ class WorktreeSessionEditorPanel(
bindModel()
bindTheme()
manager.onPresent = { key -> select(key) }
manager.onListChanged = { sync() }
manager.onListChanged = {
sync()
project?.service<WorktreeStatusService>()?.refreshStats()
}
ActionManager.getInstance().getAction("RenameElement")?.shortcutSet?.let { set ->
rename.registerCustomShortcutSet(set, list, this)
}
addHierarchyListener {
if (isShowing) start()
if (isShowing) {
start()
project?.service<WorktreeStatusService>()?.refreshStats()
project?.service<WorktreeStatusService>()?.refreshPr()
}
}
bindStatus()
sync()
}
@@ -171,6 +199,8 @@ class WorktreeSessionEditorPanel(
if (started) return
started = true
manager.start()
project?.service<WorktreeStatusService>()?.refreshStats()
project?.service<WorktreeStatusService>()?.refreshPr()
}
@RequiresEdt
@@ -188,9 +218,20 @@ class WorktreeSessionEditorPanel(
}.apply {
border = IdeBorderFactory.createBorder(SideBorder.BOTTOM)
add(toolbar.component, BorderLayout.WEST)
add(statsView, BorderLayout.EAST)
}
}
@RequiresEdt
private fun openBranchDiff() {
val target = project ?: return
ensureDiffEditorKind()
target.service<KiloVfsManager>().open(
KiloDiffEditorKind.ID,
diffParams("branch", worktree.directory, null, KiloBundle.message("diff.editor.branch.title")),
)
}
@RequiresEdt
private fun open(row: ActiveListItem, focus: Boolean) {
if (row.key == SessionHost.NEW) {
@@ -258,6 +299,34 @@ class WorktreeSessionEditorPanel(
})
}
private fun bindStatus() {
val target = project ?: return
val service = target.service<WorktreeStatusService>()
status = service.attach()
cs.launch {
service.stats.collectLatest { value ->
edtIfAlive {
stats = value[normalizeWorktreePath(worktree.directory)]
statsView.update(stats, pr)
}
}
}
cs.launch {
service.pr.collectLatest { value ->
edtIfAlive {
pr = value[normalizeWorktreePath(worktree.directory)]
statsView.update(stats, pr)
}
}
}
}
private fun edtIfAlive(block: () -> Unit) {
ApplicationManager.getApplication().invokeLater {
if ((project == null || !project.isDisposed) && !Disposer.isDisposed(this)) block()
}
}
override fun uiDataSnapshot(sink: DataSink) {
sink[SessionManager.KEY] = manager
sink[SessionManager.WORKSPACE_KEY] = worktree
@@ -266,6 +335,8 @@ class WorktreeSessionEditorPanel(
override fun dispose() {
manager.onPresent = null
manager.onListChanged = null
status?.close()
cs.cancel()
}
private inner class NewAction : AnAction(
@@ -0,0 +1,118 @@
package ai.kilocode.client.agentManager.worktree
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.ui.DiffStatBadge
import ai.kilocode.client.ui.FilledBadgeIcon
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.list.ActiveListBadge
import ai.kilocode.client.ui.layout.Stack
import ai.kilocode.rpc.dto.GhState
import ai.kilocode.rpc.dto.WorktreePrDto
import ai.kilocode.rpc.dto.WorktreeStatsDto
import com.intellij.ide.BrowserUtil
import com.intellij.openapi.util.IconLoader
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.JBFont
import com.intellij.util.ui.JBUI
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
internal class WorktreeStatsView(
private val openDiff: (() -> Unit)? = null,
) : JPanel(null) {
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)
}
private val behind = count(DOWN)
private val ahead = count(UP)
private val diff = DiffStatBadge(0, 0, DiffStatBadge.Variant.COMPACT)
private val pr = JBLabel()
private val row = Stack.horizontal(UiStyle.Gap.sm()).next(behind).next(ahead).next(diff).next(pr)
private var url: String? = null
private var stats: WorktreeStatsDto? = null
private var pull: WorktreePrDto? = null
init {
add(row)
diff.cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR).takeIf { openDiff != null } ?: Cursor.getDefaultCursor()
diff.toolTipText = KiloBundle.message("worktree.stats.diff.tooltip", 0, 0)
diff.addMouseListener(object : MouseAdapter() {
override fun mouseClicked(event: MouseEvent) {
openDiff?.invoke()
}
})
pr.cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
pr.addMouseListener(object : MouseAdapter() {
override fun mouseClicked(event: MouseEvent) {
url?.let(BrowserUtil::browse)
}
})
}
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 { KiloBundle.message("worktree.pr.tooltip", it.number, it.state.name.lowercase()) })
}
fun update(stats: WorktreeStatsDto?, badge: ActiveListBadge?) {
if (this.stats == stats && pull == null && (pr.icon as? FilledBadgeIcon)?.text == badge?.text) return
this.stats = stats
this.pull = null
sync(stats, badge, null, badge?.text)
}
private fun sync(stats: WorktreeStatsDto?, badge: ActiveListBadge?, link: String?, tip: String?) {
val s = stats ?: WorktreeStatsDto("")
behind.text = s.behind.toString()
behind.toolTipText = KiloBundle.message("worktree.stats.behind.tooltip")
behind.isVisible = s.behind > 0
ahead.text = s.ahead.toString()
ahead.toolTipText = KiloBundle.message("worktree.stats.ahead.tooltip")
ahead.isVisible = s.ahead > 0
diff.update(s.additions, s.deletions)
diff.isVisible = s.additions > 0 || s.deletions > 0
diff.toolTipText = KiloBundle.message("worktree.stats.diff.tooltip", s.additions, s.deletions)
url = link
pr.icon = badge?.let { FilledBadgeIcon(it.text, it.style) }
pr.toolTipText = tip
pr.isVisible = badge != null
isVisible = behind.isVisible || ahead.isVisible || diff.isVisible || pr.isVisible
revalidate()
repaint()
}
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()
font = JBFont.small()
foreground = UiStyle.Colors.weak()
border = JBUI.Borders.empty()
}
}
internal fun style(state: GhState): UiStyle.Badge.Style = when (state) {
GhState.OPEN -> UiStyle.Badge.Primary
GhState.DRAFT -> UiStyle.Badge.Secondary
GhState.MERGED -> UiStyle.Badge.Highlight
GhState.CLOSED -> UiStyle.Badge.Alert
}
@@ -0,0 +1,129 @@
package ai.kilocode.client.agentManager.worktree
import ai.kilocode.client.KiloNotifications
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.util.UiTimer
import ai.kilocode.client.util.UiTimerSource
import ai.kilocode.client.util.UiTimers
import ai.kilocode.log.KiloLog
import ai.kilocode.rpc.dto.GhAvailability
import ai.kilocode.rpc.dto.WorktreePrDto
import ai.kilocode.rpc.dto.WorktreeStatsDto
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
@Service(Service.Level.PROJECT)
class WorktreeStatusService internal constructor(
private val project: Project,
private val cs: CoroutineScope,
private val timers: UiTimerSource = UiTimers,
) {
constructor(project: Project, cs: CoroutineScope) : this(project, cs, UiTimers)
companion object {
private val LOG = KiloLog.create(WorktreeStatusService::class.java)
private const val STATS_DEBOUNCE = 300
private const val STATS_POLL = 30_000
private const val PR_POLL = 120_000
private const val PR_THROTTLE = 30_000L
}
private val statsFlow = MutableStateFlow<Map<String, WorktreeStatsDto>>(emptyMap())
private val prFlow = MutableStateFlow<Map<String, WorktreePrDto>>(emptyMap())
private val ghFlow = MutableStateFlow(GhAvailability.OK)
private var debounce: UiTimer? = null
private var statsTimer: UiTimer? = null
private var prTimer: UiTimer? = null
private var refs = 0
private var lastPr = 0L
private var notified = false
val stats: StateFlow<Map<String, WorktreeStatsDto>> get() = statsFlow
val pr: StateFlow<Map<String, WorktreePrDto>> get() = prFlow
val gh: StateFlow<GhAvailability> get() = ghFlow
fun attach(): AutoCloseable {
refs++
if (refs == 1) start()
return AutoCloseable {
refs = (refs - 1).coerceAtLeast(0)
if (refs == 0) stop()
}
}
fun refreshStats() {
if (project.isDisposed) return
val timer = debounce ?: timers.timer(STATS_DEBOUNCE, repeats = false) { loadStats() }.also { debounce = it }
timer.restart()
}
fun refreshPr(force: Boolean = false) {
if (project.isDisposed) return
val now = timers.now()
if (!force && now - lastPr < PR_THROTTLE) return
lastPr = now
loadPr()
}
private fun start() {
refreshStats()
refreshPr(force = true)
statsTimer = timers.timer(STATS_POLL) { refreshStats() }.also { it.start() }
prTimer = timers.timer(PR_POLL) { refreshPr(force = true) }.also { it.start() }
}
private fun stop() {
debounce?.stop()
statsTimer?.stop()
prTimer?.stop()
statsTimer = null
prTimer = null
}
private fun loadStats() {
val dir = project.basePath ?: return
cs.launch {
runCatching { service<KiloWorktreeService>().stats(dir) }
.onSuccess { dto -> statsFlow.value = dto.items.associateBy { normalizeWorktreePath(it.path) } }
.onFailure { err -> LOG.warn("worktree stats refresh failed dir=$dir", err) }
}
}
private fun loadPr() {
val dir = project.basePath ?: return
cs.launch {
runCatching { service<KiloWorktreeService>().prStatus(dir) }
.onSuccess { dto ->
prFlow.value = dto.items.associateBy { normalizeWorktreePath(it.path) }
ghFlow.value = dto.availability
notify(dto.availability)
}
.onFailure { err -> LOG.warn("worktree PR refresh failed dir=$dir", err) }
}
}
private fun notify(value: GhAvailability) {
if (notified || value == GhAvailability.OK) return
notified = true
if (value == GhAvailability.MISSING) {
KiloNotifications.suggestion(
project,
KiloBundle.message("worktree.gh.missing.title"),
KiloBundle.message("worktree.gh.missing.content"),
KiloBundle.message("worktree.gh.install"),
) { com.intellij.ide.BrowserUtil.browse("https://cli.github.com/") }
return
}
KiloNotifications.suggestion(
project,
KiloBundle.message("worktree.gh.unauth.title"),
KiloBundle.message("worktree.gh.unauth.content"),
KiloBundle.message("worktree.gh.authorize"),
) { com.intellij.ide.BrowserUtil.browse("https://cli.github.com/manual/gh_auth_login") }
}
}
@@ -24,6 +24,8 @@ interface SessionManager {
fun focusPrompt() {}
val showsBranchBadgeInHeader: Boolean get() = true
fun openSession(session: SessionDto) {
openSession(SessionRef.Local(session))
}
@@ -226,7 +226,7 @@ class SessionUi(
bindStyle()
bindMigration()
onStateChanged(controller.model.state)
refreshBranchChanges()
if (showBranchBadge()) refreshBranchChanges()
loaded?.let(::finishOpen)
}
@@ -839,6 +839,11 @@ class SessionUi(
/** Badge-only refresh: fetches stats (no patch text) and updates the header count. */
private fun refreshBranchChanges() {
if (!showBranchBadge()) {
refreshJob?.cancel()
header.hideBranchChanges()
return
}
refreshJob?.cancel()
refreshJob = cs.launch {
val files = runCatching { workspaces.branchDiff(workspace.directory, patches = false) }
@@ -856,6 +861,7 @@ class SessionUi(
/** User clicked the badge: opens the branch diff editor. Never cancelled by a background refresh. */
private fun openBranchChanges() {
if (!showBranchBadge()) return
openJob?.cancel()
openJob = cs.launch {
val dir = workspace.directory
@@ -889,6 +895,8 @@ class SessionUi(
Telemetry.send("Diff Editor Opened", mapOf("source" to "branch"))
}
private fun showBranchBadge(): Boolean = manager?.showsBranchBadgeInHeader != false
private fun openAttachment(messageId: String, item: FileAttachment) {
val url = item.url.takeIf { it.isNotBlank() } ?: run {
LOG.info("kind=attachment-open skipped=true reason=blank-url message=$messageId part=${item.id} name=${attachmentName(item)} mime=${item.mime}")
@@ -310,6 +310,12 @@ class SessionHeaderPanel(
refresh()
}
fun hideBranchChanges() {
if (!changes.isVisible) return
changes.isVisible = false
refresh()
}
override fun applyStyle(style: SessionEditorStyle) {
this.style = style
background = style.editorBackground
@@ -16,6 +16,14 @@ private const val CELL_GAP = 8
internal data class ActiveListBadge(val text: String, val style: UiStyle.Badge.Style = UiStyle.Badge.Secondary)
internal data class ActiveListMetrics(
val additions: Int = 0,
val deletions: Int = 0,
val ahead: Int = 0,
val behind: Int = 0,
val pr: ActiveListBadge? = null,
)
internal enum class ActiveListRowHeight { EQUAL, PREFERRED }
internal data class ActiveListConfig(
@@ -63,6 +71,7 @@ internal interface ActiveListItem {
val badges: List<ActiveListBadge> get() = emptyList()
/** Right-aligned secondary text, such as a relative timestamp. */
val trailing: String? get() = null
val metrics: ActiveListMetrics? get() = null
val cells: List<ActiveListCell> get() = emptyList()
val disabled: Boolean get() = false
val deleting: Boolean get() = false
@@ -1,5 +1,6 @@
package ai.kilocode.client.ui.list
import ai.kilocode.client.agentManager.worktree.WorktreeStatsView
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.ui.PickerRow
import ai.kilocode.client.ui.FilledBadgeIcon
@@ -16,6 +17,7 @@ import com.intellij.ui.SimpleTextAttributes
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import ai.kilocode.rpc.dto.WorktreeStatsDto
import java.awt.BorderLayout
import java.awt.Dimension
import java.awt.Rectangle
@@ -53,7 +55,12 @@ internal class ActiveListRenderer(
private val text = Stack.vertical().next(header).next(desc)
private val textPane = text.align(HAlign.TRACK, VAlign.CENTER)
private val trail = JBLabel().apply { horizontalAlignment = SwingConstants.RIGHT }
private val metrics = WorktreeStatsView()
private val trailPane = trail.align(HAlign.RIGHT, VAlign.CENTER)
private val endPane = JPanel(BorderLayout()).apply {
add(trailPane, BorderLayout.CENTER)
add(metrics, BorderLayout.EAST)
}
private val cells = Stack.horizontal(activeListCellGap())
private val cellPane = cells.align(HAlign.RIGHT, VAlign.CENTER)
private val pill = JPanel(BorderLayout()).apply {
@@ -63,7 +70,7 @@ internal class ActiveListRenderer(
private val row = JPanel(BorderLayout(UiStyle.Gap.md(), 0)).apply {
add(mark, BorderLayout.WEST)
add(textPane, BorderLayout.CENTER)
add(trailPane, BorderLayout.EAST)
add(endPane, BorderLayout.EAST)
}
private val layers = LayeredOverlayPanel(
content = JPanel(BorderLayout()).apply { add(row, BorderLayout.CENTER) },
@@ -88,7 +95,9 @@ internal class ActiveListRenderer(
textPane,
desc,
trail,
metrics,
trailPane,
endPane,
cells,
cellPane,
)
@@ -154,9 +163,12 @@ internal class ActiveListRenderer(
JBUI.Borders.empty()
}
desc.foreground = weak
val data = if (value.deleting) null else value.metrics
metrics.update(data?.let { WorktreeStatsDto("", it.additions, it.deletions, it.ahead, it.behind) }, data?.pr)
val end = if (value.deleting) KiloBundle.message("common.deleting") else value.trailing.orEmpty()
trail.text = end
trail.isVisible = end.isNotBlank()
trail.isVisible = end.isNotBlank() && data == null
metrics.isVisible = data != null && !value.deleting
trail.foreground = weak
val hovered = (list as? ActiveListActive)?.hoveredIndex() == index
@@ -4,6 +4,7 @@ common.open=Open
common.rename=Rename
common.rename.help=Use a custom name that describes your task.
common.save=Save
common.dont.show.again=Don''t show again
session.action.cancel=Cancel
session.connection.connecting=Loading...
@@ -364,6 +365,16 @@ worktree.configure.title=New Worktree
worktree.configure.branch=Branch name:
worktree.configure.base=Base branch:
worktree.configure.branch.required=Branch name is required
worktree.stats.diff.tooltip={0} additions, {1} deletions
worktree.stats.ahead.tooltip=Commits ahead of base branch
worktree.stats.behind.tooltip=Commits behind base branch
worktree.pr.tooltip=Pull request #{0} ({1})
worktree.gh.missing.title=GitHub CLI not found
worktree.gh.missing.content=Install gh to show pull request badges for worktrees.
worktree.gh.install=Install
worktree.gh.unauth.title=GitHub CLI not authorized
worktree.gh.unauth.content=Authorize gh to show pull request badges for worktrees.
worktree.gh.authorize=Authorize
action.Kilo.Settings.text=Settings
action.Kilo.Settings.description=Kilo Code settings
@@ -6,20 +6,25 @@ import ai.kilocode.client.agentManager.worktree.WorktreeController
import ai.kilocode.client.agentManager.worktree.WorktreeEditorMatcher
import ai.kilocode.client.agentManager.worktree.WorktreeEditorMatchers
import ai.kilocode.client.agentManager.worktree.WorktreeSessionEditorKind
import ai.kilocode.client.agentManager.worktree.WorktreeStatusService
import ai.kilocode.client.agentManager.worktree.ensureWorktreeSessionEditorKind
import ai.kilocode.client.agentManager.worktree.worktreeSessionParams
import ai.kilocode.client.testing.FakeWorktreeRpcApi
import ai.kilocode.client.testing.TestCoroutines
import ai.kilocode.client.testing.TestUiTimers
import ai.kilocode.client.testing.fire
import ai.kilocode.client.session.SessionActivityKind
import ai.kilocode.client.ui.list.ActiveListBadge
import ai.kilocode.client.ui.list.ActiveListItem
import ai.kilocode.client.ui.list.ActiveListMetrics
import ai.kilocode.client.ui.list.activeListToolWindowBackground
import ai.kilocode.client.vfs.KiloPath
import ai.kilocode.client.vfs.KiloVfsManager
import ai.kilocode.client.vfs.KiloVirtualFile
import ai.kilocode.client.vfs.KiloVirtualFileSystem
import ai.kilocode.rpc.dto.WorktreeDto
import ai.kilocode.rpc.dto.WorktreeStatsDto
import ai.kilocode.rpc.dto.WorktreeStatsListDto
import ai.kilocode.rpc.dto.SessionActivityDto
import ai.kilocode.rpc.dto.SessionActivityKindDto
import com.intellij.openapi.application.ApplicationManager
@@ -28,6 +33,7 @@ import com.intellij.openapi.fileEditor.FileEditorManager
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.components.JBList
import com.intellij.ui.components.JBScrollPane
import com.intellij.util.ui.UIUtil
@@ -60,7 +66,7 @@ class AgentManagerPanelTest : BasePlatformTestCase() {
fun `test creating a worktree selects it while pending and after the rpc resolves`() {
rpc.listed += WorktreeDto("/repo/.kilo/worktrees/feature-x", "feature-x", "feature/x", "/repo/.kilo/worktrees/feature-x")
val controller = WorktreeController(service, "/test", coroutines.scope)
val controller = WorktreeController(service, project.basePath!!, coroutines.scope)
val panel = edt { AgentManagerPanel(testRootDisposable, controller) }
edt { controller.reload() }
flush()
@@ -121,7 +127,7 @@ class AgentManagerPanelTest : BasePlatformTestCase() {
}
fun `test clicking a worktree opens the worktree session editor`() {
val item = WorktreeDto("/repo/.kilo/worktrees/feature-x", "feature-x", "feature/x", "/repo/.kilo/worktrees/feature-x")
val item = WorktreeDto("/repo/.kilo/worktrees/feature-x", "feature-x", "feature/x", "${project.basePath!!}/.kilo/worktrees/feature-x")
rpc.listed += item
val controller = WorktreeController(service, "/test", coroutines.scope)
val panel = edt { AgentManagerPanel(testRootDisposable, controller, project) }
@@ -390,6 +396,26 @@ class AgentManagerPanelTest : BasePlatformTestCase() {
assertEquals(listOf(ActiveListBadge(SessionActivityKind.QUESTION.label(), SessionActivityKind.QUESTION.style())), row.badges)
}
fun `test worktree row shows metrics from status service`() {
val item = WorktreeDto("${project.basePath!!}/.kilo/worktrees/feature-x", "feature-x", "feature/x", "${project.basePath!!}/.kilo/worktrees/feature-x")
rpc.listed += item
rpc.statsResult = WorktreeStatsListDto(listOf(WorktreeStatsDto(item.path, additions = 5, deletions = 2, ahead = 1, behind = 3)))
val timers = TestUiTimers()
ApplicationManager.getApplication().replaceService(KiloWorktreeService::class.java, service, testRootDisposable)
project.replaceService(WorktreeStatusService::class.java, WorktreeStatusService(project, coroutines.scope, timers), testRootDisposable)
val controller = WorktreeController(service, project.basePath!!, coroutines.scope)
val panel = edt { AgentManagerPanel(testRootDisposable, controller, project) }
edt { controller.reload() }
timers.advanceBy(300)
flush()
val metrics: ActiveListMetrics = row(panel, 0).metrics ?: error("expected metrics")
assertEquals(5, metrics.additions)
assertEquals(2, metrics.deletions)
assertEquals(1, metrics.ahead)
assertEquals(3, metrics.behind)
}
fun `test worktree row hides badge while pending or deleting`() {
val path = "feature/y"
val activity = MutableStateFlow(mapOf(
@@ -8,6 +8,8 @@ import ai.kilocode.rpc.dto.RenameWorktreeResultDto
import ai.kilocode.rpc.dto.WorktreeBranchesDto
import ai.kilocode.rpc.dto.WorktreeDto
import ai.kilocode.rpc.dto.WorktreeListDto
import ai.kilocode.rpc.dto.WorktreePrListDto
import ai.kilocode.rpc.dto.WorktreeStatsListDto
import java.util.concurrent.CopyOnWriteArrayList
/**
@@ -17,6 +19,8 @@ import java.util.concurrent.CopyOnWriteArrayList
class FakeWorktreeRpcApi : KiloWorktreeRpcApi {
val listed = CopyOnWriteArrayList<WorktreeDto>()
val branchesList = CopyOnWriteArrayList<String>()
var statsResult = WorktreeStatsListDto()
var prResult = WorktreePrListDto()
var currentBranch: String? = null
val creates = CopyOnWriteArrayList<CreateWorktreeRequestDto>()
val removes = CopyOnWriteArrayList<Triple<String, String, String?>>()
@@ -52,6 +56,16 @@ class FakeWorktreeRpcApi : KiloWorktreeRpcApi {
return WorktreeBranchesDto(branchesList.toList(), currentBranch)
}
override suspend fun stats(directory: String): WorktreeStatsListDto {
assertNotEdt("stats")
return statsResult
}
override suspend fun prStatus(directory: String): WorktreePrListDto {
assertNotEdt("prStatus")
return prResult
}
override suspend fun create(directory: String, request: CreateWorktreeRequestDto): CreateWorktreeResultDto {
assertNotEdt("create")
creates.add(request)
@@ -6,6 +6,8 @@ import ai.kilocode.rpc.dto.RemoveWorktreeResultDto
import ai.kilocode.rpc.dto.RenameWorktreeResultDto
import ai.kilocode.rpc.dto.WorktreeBranchesDto
import ai.kilocode.rpc.dto.WorktreeListDto
import ai.kilocode.rpc.dto.WorktreePrListDto
import ai.kilocode.rpc.dto.WorktreeStatsListDto
import com.intellij.platform.rpc.RemoteApiProviderService
import fleet.rpc.RemoteApi
import fleet.rpc.Rpc
@@ -26,6 +28,8 @@ interface KiloWorktreeRpcApi : RemoteApi<Unit> {
}
suspend fun list(directory: String): WorktreeListDto
suspend fun stats(directory: String): WorktreeStatsListDto
suspend fun prStatus(directory: String): WorktreePrListDto
suspend fun listBranches(directory: String): WorktreeBranchesDto
suspend fun create(directory: String, request: CreateWorktreeRequestDto): CreateWorktreeResultDto
suspend fun remove(directory: String, path: String, branch: String? = null, force: Boolean = false): RemoveWorktreeResultDto
@@ -16,6 +16,38 @@ data class WorktreeDto(
@Serializable
data class WorktreeListDto(val worktrees: List<WorktreeDto> = emptyList())
@Serializable
data class WorktreeStatsDto(
val path: String,
val additions: Int = 0,
val deletions: Int = 0,
val ahead: Int = 0,
val behind: Int = 0,
)
@Serializable
data class WorktreeStatsListDto(val items: List<WorktreeStatsDto> = emptyList())
@Serializable
enum class GhState { OPEN, DRAFT, MERGED, CLOSED }
@Serializable
data class WorktreePrDto(
val path: String,
val number: Int,
val state: GhState,
val url: String,
)
@Serializable
enum class GhAvailability { OK, MISSING, UNAUTH }
@Serializable
data class WorktreePrListDto(
val availability: GhAvailability = GhAvailability.OK,
val items: List<WorktreePrDto> = emptyList(),
)
@Serializable
data class WorktreeBranchesDto(
val branches: List<String> = emptyList(),