From 4100415acab6efaacf438ccb0648e29acd7b22a2 Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 5 Aug 2026 20:25:12 -0400 Subject: [PATCH] feat(jetbrains): show worktree change and PR badges --- .../jetbrains-worktree-change-badges.md | 5 + ...4743679-worktree-session-deleting-state.md | 100 ++++++ ...32479482-jetbrains-agent-manager-rename.md | 194 ++++++++++++ ...s-agent-manager-worktree-activity-badge.md | 287 ++++++++++++++++++ ...04279356-active-renderer-action-overlay.md | 158 ++++++++++ ...89469295-worktree-list-editor-selection.md | 219 +++++++++++++ .kilo/plans/1785780918840-hover-action-bar.md | 149 +++++++++ ...793160537-history-active-list-migration.md | 189 ++++++++++++ ...563158-jetbrains-worktree-change-badges.md | 273 +++++++++++++++++ .../backend/rpc/KiloWorktreeRpcApiImpl.kt | 188 ++++++++++++ .../backend/rpc/KiloWorktreeRpcApiImplTest.kt | 18 ++ .../ai/kilocode/client/KiloNotifications.kt | 12 + .../client/agentManager/AgentManagerPanel.kt | 75 ++++- .../worktree/KiloWorktreeService.kt | 16 + .../worktree/WorktreeSessionEditorKind.kt | 2 +- .../worktree/WorktreeSessionEditorManager.kt | 1 + .../worktree/WorktreeSessionEditorPanel.kt | 75 ++++- .../worktree/WorktreeStatsView.kt | 118 +++++++ .../worktree/WorktreeStatusService.kt | 129 ++++++++ .../kilocode/client/session/SessionManager.kt | 2 + .../ai/kilocode/client/session/SessionUi.kt | 10 +- .../session/ui/header/SessionHeaderPanel.kt | 6 + .../client/ui/list/ActiveListModel.kt | 9 + .../client/ui/list/ActiveListRenderer.kt | 16 +- .../resources/messages/KiloBundle.properties | 11 + .../agentManager/AgentManagerPanelTest.kt | 30 +- .../client/testing/FakeWorktreeRpcApi.kt | 14 + .../ai/kilocode/rpc/KiloWorktreeRpcApi.kt | 4 + .../kotlin/ai/kilocode/rpc/dto/WorktreeDto.kt | 32 ++ 29 files changed, 2332 insertions(+), 10 deletions(-) create mode 100644 .changeset/jetbrains-worktree-change-badges.md create mode 100644 .kilo/plans/1785184743679-worktree-session-deleting-state.md create mode 100644 .kilo/plans/1785332479482-jetbrains-agent-manager-rename.md create mode 100644 .kilo/plans/1785441624114-jetbrains-agent-manager-worktree-activity-badge.md create mode 100644 .kilo/plans/1785504279356-active-renderer-action-overlay.md create mode 100644 .kilo/plans/1785689469295-worktree-list-editor-selection.md create mode 100644 .kilo/plans/1785780918840-hover-action-bar.md create mode 100644 .kilo/plans/1785793160537-history-active-list-migration.md create mode 100644 .kilo/plans/1785951563158-jetbrains-worktree-change-badges.md create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatsView.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusService.kt diff --git a/.changeset/jetbrains-worktree-change-badges.md b/.changeset/jetbrains-worktree-change-badges.md new file mode 100644 index 00000000000..788be41920b --- /dev/null +++ b/.changeset/jetbrains-worktree-change-badges.md @@ -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. diff --git a/.kilo/plans/1785184743679-worktree-session-deleting-state.md b/.kilo/plans/1785184743679-worktree-session-deleting-state.md new file mode 100644 index 00000000000..657a49867a5 --- /dev/null +++ b/.kilo/plans/1785184743679-worktree-session-deleting-state.md @@ -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`), exposed as `deleting(): Set`. 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()` and `@RequiresEdt fun deleting(): Set = 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. diff --git a/.kilo/plans/1785332479482-jetbrains-agent-manager-rename.md b/.kilo/plans/1785332479482-jetbrains-agent-manager-rename.md new file mode 100644 index 00000000000..7cfdcf8ed0c --- /dev/null +++ b/.kilo/plans/1785332479482-jetbrains-agent-manager-rename.md @@ -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** `/.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, names: Map): List` — for each non-main item, if `names[path]` is present and non-blank, return `copy(name = names[path])`. + - `internal fun readWorktreeNames(file: Path): Map` / `internal fun writeWorktreeNames(file: Path, map: Map)` — kotlinx.serialization `Json` over `Map` (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 `/.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 = .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` path→name with `get(path)`, `put(path, name)`, `remove(path)`, `putAll(items: List)`. Used only to feed the editor-tab title synchronously. + +### 5. Worktree rename wiring — `WorktreeController.kt` + `AgentManagerPanel.kt` + +`WorktreeController`: + +- On `reload()` success, `service().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()?.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().get(it) ?: name(it) } ?: `. +- (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). diff --git a/.kilo/plans/1785441624114-jetbrains-agent-manager-worktree-activity-badge.md b/.kilo/plans/1785441624114-jetbrains-agent-manager-worktree-activity-badge.md new file mode 100644 index 00000000000..b91ec899456 --- /dev/null +++ b/.kilo/plans/1785441624114-jetbrains-agent-manager-worktree-activity-badge.md @@ -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`), 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>` (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()`. 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>`. +- `questions: MutableMap>` + where `plan = request.questions.any { it.questionKey == "plan.followup.question" || it.headerKey == "plan.followup.header" }` + (mirrors `SessionUi.planFollowup`). +- `_activity = MutableStateFlow>(emptyMap())`; + `val activity: StateFlow<...> = _activity.asStateFlow()`. + +`start(sessions: KiloBackendSessionManager, chatEvents: SharedFlow)`: + +- 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`. + +`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> = 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> = + stream { activity() }.stateIn(cs, SharingStarted.Eagerly, emptyMap()) +``` + +`FakeSessionRpcApi` (test double): add `val activity = MutableStateFlow>(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, +): Map +``` + +- 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> = MutableStateFlow(emptyMap())` + (production: `project.service().activity`, passed from + `KiloToolWindowFactory`). +- Add `@Volatile var kinds: Map = 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(), workspace.directory, cs, activity = project.service().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). diff --git a/.kilo/plans/1785504279356-active-renderer-action-overlay.md b/.kilo/plans/1785504279356-active-renderer-action-overlay.md new file mode 100644 index 00000000000..d4615390e73 --- /dev/null +++ b/.kilo/plans/1785504279356-active-renderer-action-overlay.md @@ -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`, `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. diff --git a/.kilo/plans/1785689469295-worktree-list-editor-selection.md b/.kilo/plans/1785689469295-worktree-list-editor-selection.md new file mode 100644 index 00000000000..7747a18eda7 --- /dev/null +++ b/.kilo/plans/1785689469295-worktree-list-editor-selection.md @@ -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() + + 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().register(WorktreeSessionEditorMatcher)`. +- Subscribe to editor selection and seed the initial state: + +```kotlin +project?.let { p -> + p.service().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().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().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().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()` 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`. diff --git a/.kilo/plans/1785780918840-hover-action-bar.md b/.kilo/plans/1785780918840-hover-action-bar.md new file mode 100644 index 00000000000..ad17766fbf4 --- /dev/null +++ b/.kilo/plans/1785780918840-hover-action-bar.md @@ -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). diff --git a/.kilo/plans/1785793160537-history-active-list-migration.md b/.kilo/plans/1785793160537-history-active-list-migration.md new file mode 100644 index 00000000000..8185e0ee484 --- /dev/null +++ b/.kilo/plans/1785793160537-history-active-list-migration.md @@ -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, snapshot: HistoryActivitySnapshot, deleting: (String) -> Boolean): List` + - `internal fun cloudRows(items: List, snapshot: HistoryActivitySnapshot): List` + - 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` (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` 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. diff --git a/.kilo/plans/1785951563158-jetbrains-worktree-change-badges.md b/.kilo/plans/1785951563158-jetbrains-worktree-change-badges.md new file mode 100644 index 00000000000..7c627c39144 --- /dev/null +++ b/.kilo/plans/1785951563158-jetbrains-worktree-change-badges.md @@ -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 `. 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> / > / 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 = 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 = 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 ` (fall back to `base` on failure). +- Diff: `git -c core.quotepath=false diff --numstat --no-renames ` → 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 ...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 --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>` (keyed by normalized path) + - `pr: StateFlow>` + - `gh: StateFlow` +- `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. diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt index 18c7e76d501..2d543fa331a 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt @@ -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>() + private val prs = ConcurrentHashMap>() + @Volatile + private var ghProbe: Timed? = 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(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 parallel(items: List, block: suspend (T) -> R): List = 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 { + 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 +} diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt index f4a7d5f267d..9552fbfeece 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt @@ -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") diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloNotifications.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloNotifications.kt index 0a052ee9411..c59e7b754c9 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloNotifications.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloNotifications.kt @@ -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) + } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt index 7bba4d1b504..71c1de91e43 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt @@ -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 = emptyMap() + private var prs: Map = 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()?.refreshStats() + } + bindStatus() bindEditorSelection() // Reflect names adopted or renamed in a worktree session editor tab in the list live. service().addListener(this) { path, name -> controller.applyName(path, name) } @@ -120,6 +140,8 @@ class AgentManagerPanel( fun refresh() { selected = currentEditorWorktree() controller.reload() + project?.service()?.refreshStats() + project?.service()?.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() + 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 get() = if (dto.main || pending) emptyList() else listOf( activeListRenameCell(KiloBundle.message("worktree.rename.action")), diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/KiloWorktreeService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/KiloWorktreeService.kt index d27dbd08de7..04b567b91ec 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/KiloWorktreeService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/KiloWorktreeService.kt @@ -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) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorKind.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorKind.kt index d6165b1c5ff..14869f7bcea 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorKind.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorKind.kt @@ -43,7 +43,7 @@ object WorktreeSessionEditorKind : KiloEditorKind { Disposer.register(parent) { cs.cancel() } val controller = WorktreeSessionListController(project.service(), 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 { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorManager.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorManager.kt index b565237afbe..d68eca94356 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorManager.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorManager.kt @@ -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() private var last: String? = null diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanel.kt index 1bdb4bd307d..3d0b84ed33f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanel.kt @@ -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()?.refreshStats() + } ActionManager.getInstance().getAction("RenameElement")?.shortcutSet?.let { set -> rename.registerCustomShortcutSet(set, list, this) } addHierarchyListener { - if (isShowing) start() + if (isShowing) { + start() + project?.service()?.refreshStats() + project?.service()?.refreshPr() + } } + bindStatus() sync() } @@ -171,6 +199,8 @@ class WorktreeSessionEditorPanel( if (started) return started = true manager.start() + project?.service()?.refreshStats() + project?.service()?.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().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() + 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( diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatsView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatsView.kt new file mode 100644 index 00000000000..52130965ed4 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatsView.kt @@ -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 +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusService.kt new file mode 100644 index 00000000000..d73e4c5e3f4 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusService.kt @@ -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>(emptyMap()) + private val prFlow = MutableStateFlow>(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> get() = statsFlow + val pr: StateFlow> get() = prFlow + val gh: StateFlow 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().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().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") } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionManager.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionManager.kt index 7363654d019..b74c97c5ed2 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionManager.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionManager.kt @@ -24,6 +24,8 @@ interface SessionManager { fun focusPrompt() {} + val showsBranchBadgeInHeader: Boolean get() = true + fun openSession(session: SessionDto) { openSession(SessionRef.Local(session)) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 2348dbcbe92..d4b62d08fbd 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -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}") diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt index 88dc42bfee4..53dd1b6790e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt index 6172962d322..1e75833692c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListModel.kt @@ -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 get() = emptyList() /** Right-aligned secondary text, such as a relative timestamp. */ val trailing: String? get() = null + val metrics: ActiveListMetrics? get() = null val cells: List get() = emptyList() val disabled: Boolean get() = false val deleting: Boolean get() = false diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt index 0f654391797..b86c39474d7 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt @@ -1,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 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index cd2264888de..4b246fd0720 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt index 95f752e75a4..82864377f78 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt @@ -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( diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorktreeRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorktreeRpcApi.kt index 8d93748827e..0f2d8b9af28 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorktreeRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorktreeRpcApi.kt @@ -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() val branchesList = CopyOnWriteArrayList() + var statsResult = WorktreeStatsListDto() + var prResult = WorktreePrListDto() var currentBranch: String? = null val creates = CopyOnWriteArrayList() val removes = CopyOnWriteArrayList>() @@ -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) diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorktreeRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorktreeRpcApi.kt index b8a0ddc28de..74af2185c5d 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorktreeRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorktreeRpcApi.kt @@ -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 { } 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 diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/WorktreeDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/WorktreeDto.kt index bf9008691c5..c1f4f0dabff 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/WorktreeDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/WorktreeDto.kt @@ -16,6 +16,38 @@ data class WorktreeDto( @Serializable data class WorktreeListDto(val worktrees: List = 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 = 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 = emptyList(), +) + @Serializable data class WorktreeBranchesDto( val branches: List = emptyList(),