mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-21 14:07:20 +08:00
feat(jetbrains): show worktree change and PR badges
This commit is contained in:
@@ -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 (right‑aligned). Hide the existing
|
||||
branch‑changes badge in the session **header** only for sessions running inside a worktree
|
||||
editor; keep it for tool‑window sessions.
|
||||
|
||||
## Confirmed decisions
|
||||
|
||||
- **Fetch model:** ONE batched backend RPC per refresh for git stats (numstat + rev‑list
|
||||
ahead/behind for every managed worktree) and a separate batched gh RPC for PRs. A shared
|
||||
project‑level frontend service owns the `StateFlow`s and refreshes on discrete triggers
|
||||
(list load, session turn‑end/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 base‑branch 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/` (Kilo‑owned).** No `kilocode_change`
|
||||
markers, no `packages/opencode/` edits, no SDK regen, no CLI pin bump. Git/gh run in the
|
||||
JetBrains backend in‑process (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/module‑XML 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 (single‑word fields, nullable‑safe 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 non‑main worktrees, then:
|
||||
|
||||
**`stats(directory)`** — for each managed non‑main 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 already‑parsed main entry). Cache resolution
|
||||
per worktree path with a short TTL (~60s) to avoid re‑resolving 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 per‑worktree 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 login‑shell PATH from
|
||||
`EnvironmentUtil` is used (fixes GUI‑launched 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 per‑worktree 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 (~60–120s) 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)` off‑EDT 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 → safe‑default 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 retained‑component 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 purple‑ish/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 per‑row allocation), matching
|
||||
the existing `syncBadges`/`syncCells` reuse pattern. Ensure `activeListCellBounds` hit‑testing
|
||||
is unaffected (metrics area is non‑interactive 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 hit‑test 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 bottom‑bordered 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 per‑session
|
||||
`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 TTL‑cached.
|
||||
- Frontend refresh is event‑driven (list load, turn‑end, editor select, tool‑window visible),
|
||||
debounced, with a slow safety poll that runs **only while a Kilo surface is visible**.
|
||||
- gh short‑circuits to no per‑worktree calls when the probe says `MISSING`.
|
||||
- Worktree editor sessions no longer issue their own per‑session `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 + rev‑list ahead/behind against a seeded
|
||||
worktree; untracked line counting; base resolution upstream‑vs‑main fallback; `prStatus`
|
||||
gh‑missing classification (fake `gh` on PATH or a `runGh` seam) → `MISSING`, and stderr
|
||||
auth‑failure → `UNAUTH`; per‑worktree failure yields zeros.
|
||||
- **Frontend** (`BasePlatformTestCase`, real EDT, fake RPC): `WorktreeStatusService` maps flow
|
||||
→ row metrics and toolbar view; `AgentManagerPanelTest` renders diff/ahead‑behind/PR pill;
|
||||
`WorktreeSessionEditorPanelTest` shows the toolbar badge; retained‑component tests for
|
||||
`WorktreeStatsView` (`update()` mutates without rebuilding, no‑op 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 tool‑window 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
|
||||
right‑aligned, 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 retained‑Swing 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` (login‑shell PATH) or GUI‑launched IDEs on
|
||||
macOS won't find Homebrew `gh`.
|
||||
|
||||
## Out of scope (v1)
|
||||
|
||||
- gh checks / review decision / unresolved‑comment 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 CLI‑pin work.
|
||||
+188
@@ -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
|
||||
}
|
||||
|
||||
+18
@@ -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")
|
||||
|
||||
+12
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+73
-2
@@ -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")),
|
||||
|
||||
+16
@@ -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) }
|
||||
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+73
-2
@@ -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(
|
||||
|
||||
+118
@@ -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
|
||||
}
|
||||
+129
@@ -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") }
|
||||
}
|
||||
}
|
||||
+2
@@ -24,6 +24,8 @@ interface SessionManager {
|
||||
|
||||
fun focusPrompt() {}
|
||||
|
||||
val showsBranchBadgeInHeader: Boolean get() = true
|
||||
|
||||
fun openSession(session: SessionDto) {
|
||||
openSession(SessionRef.Local(session))
|
||||
}
|
||||
|
||||
+9
-1
@@ -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}")
|
||||
|
||||
+6
@@ -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
|
||||
|
||||
+9
@@ -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
|
||||
|
||||
+14
-2
@@ -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
|
||||
|
||||
+28
-2
@@ -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(
|
||||
|
||||
+14
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user