feat(jetbrains): match worktree session rows to history and persist new sessions

Render Agent Manager worktree session rows with the same activity badges,
relative timestamps, and date sections as the History list, via an additive
trailing text column on the shared ActiveList renderer. Creating a new session
in a worktree editor now persists a real session (and auto-creates one when
opening an empty worktree) instead of showing an ephemeral blank. Hide the
search fields on the worktree and worktree session lists.
This commit is contained in:
kirillk
2026-07-27 16:42:04 -04:00
parent 15efe98cb8
commit 89163e7e02
14 changed files with 509 additions and 109 deletions
@@ -2,4 +2,4 @@
"@kilocode/kilo-jetbrains": minor
---
Support managing sessions directly from Agent Manager worktree editor tabs and hide non-Agent-Manager git worktrees from the worktree list.
Support managing sessions directly from Agent Manager worktree editor tabs, start a new session when opening an empty worktree editor, and hide non-Agent-Manager git worktrees from the worktree list.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Show Agent Manager worktree sessions with History-style activity badges, relative timestamps, and date sections.
@@ -0,0 +1,211 @@
# Worktree Session Editor — Split panel with per-worktree session list
## Goal
Replace the empty worktree editor (`WorktreeSessionEditorKind.createContent`, currently
`BorderLayoutPanel()`) with a left/right split:
- **Left (~25% width):** a session list for that worktree using `ActiveList`, with a standard
toolbar (`+` / delete), a search bar, per-row delete buttons, and multi-selection.
- **Right (~75% width):** a container hosting the selected session's `SessionUi`, driven by a new
**editor session manager** that shares the SessionUi lifecycle machinery with the sidebar.
Every worktree can add/delete sessions; all its sessions are listed and controllable from the left
list.
All work is in `packages/kilo-jetbrains/` (Kilo-owned — **no `kilocode_change` markers**). Swing on
EDT only (`@RequiresEdt`), light services, injected coroutine scopes, IntelliJ platform components +
theme colors, `KiloBundle` strings. No Kotlin UI DSL, Compose, or JCEF.
## Confirmed decisions
1. **Extraction:** abstract base class `SessionHost` in the `session` package holding the SessionUi
cache/lifecycle. `SessionSidePanelManager` and the new `WorktreeSessionEditorManager` both extend
it.
2. **Right-panel state:** always show a `SessionUi`. On open, show the most recent existing session;
if none, a new blank session. After deleting the shown session, fall back to the next session or
a new blank one (mirrors the sidebar's blank-session behavior).
3. **Left list data source:** a new lightweight `WorktreeSessionListController` backed by
`KiloSessionService.list(worktreeDir)` (no cloud/history machinery).
## Key facts (verified in code)
- Editor content lifecycle: `KiloFileEditor.ui` is `by lazy { kind.createContent(project, kilo, this) }`
(`vfs/KiloFileEditor.kt:14`); `parent` Disposable is the `KiloFileEditor`. The worktree path is in
`file.path.params["path"]` (`worktree/WorktreeSessionEditorKind.kt:22,49`).
- `SessionSidePanelManager` (`session/SessionSidePanelManager.kt`) already implements the full
SessionUi cache/lifecycle: `opened`/`all`/`activeTimers`/`current`/`latest`, `show`/`register`/
`release`/`disposeUi`/`schedule`/`cancel`/`dispose`, plus `newSession`/`openSession`/`activity`/
`titles`/`activityChanged`/`focusPrompt`. It presents by swapping content into `component`
(BorderLayout CENTER). History logic (`showHistory`/`createHistory`/`back`) is sidebar-specific.
- `SessionUi` is created via `SessionUiFactory.create(project, workspace, manager, ref, timers)`
(`session/SessionUiFactory.kt`) with `SessionUiFactory.scope()` for the coroutine scope. A blank
`SessionUi` (`ref = null`) does not hit RPC until first prompt (lazy session creation).
- `ActiveList` (`ui/list/ActiveList.kt`) already provides a search field, per-row action cells
(delete), and configurable selection. `ActiveListView` already exposes `selectedItems()`,
`selectedKeys()`, and an internal `onSelect` hook (`ui/list/ActiveListView.kt:75,124,142-152`) —
the public `ActiveList` wrapper does not surface these yet. `ActiveListConfig(selection = …)` sets
the selection mode; `ActiveListConfig`/`ActiveListRowHeight`/`ActiveListCell`/`ActiveListItem` are
`internal` in the same `frontend` module.
- Per-directory session ops on the project service `KiloSessionService`
(`app/KiloSessionService.kt`): `list(dir): SessionListDto`, `create(dir): SessionDto`,
`deleteSession(id, dir)`, `renameSession(id, dir, title)`. `HistoryController` already reads
`sessions.list(workspace.directory)` this way. Note `list(dir)` also writes a global
`_sessions` StateFlow — read the returned DTO directly, do not rely on the flow.
- Worktree workspace: `service<KiloWorkspaceService>().workspace(worktreePath)` returns a
`Workspace` for any directory (`app/KiloWorkspaceService.kt:81`).
- Splitter: no `OnePixelSplitter`/`JBSplitter` used yet in the plugin. Use
`OnePixelSplitter(false, 0.25f)` (vertical divider → left|right; first component = left = 25%).
- Progressive-load pattern already used: `SessionUi` (addNotify/doLayout) and `HistoryPanel`
(`addHierarchyListener` on `SHOWING_CHANGED`) defer work until shown.
## Implementation tasks (ordered)
### 1. Extract `SessionHost` base class
**New** `frontend/.../session/SessionHost.kt` — abstract, implements `SessionManager`, `Disposable`.
Move the reusable machinery out of `SessionSidePanelManager`:
- Fields: `opened`, `all`, `activeTimers`, `current`, `latest`.
- Constructor params (shared): `project`, `root: Workspace` (default workspace for new sessions),
`create` factory, `resolve`, `status`, `timers`, `request`.
- Methods: `newSession()`, `openSession(ref)`, `create(ref)`, `show(ui)`, `register`, `release`,
`disposeUi`, `schedule`, `cancel`, `activity()`, `titles()`, `focusPrompt()`, base `dispose()`.
- Replace the inline `component.removeAll()/add(ui)` in `show()` with an abstract
`present(ui: SessionUi?)` hook. `show(ui)` keeps: cancel/`all.add`/register/`latest =`/
`if (current === ui) return`/`release(current)`/`current = ui`/`present(ui)`/focus.
- Add open hooks: `protected open fun onSessionsChanged() {}` (invoked after create/dispose so the
editor list can refresh) and `open fun activityChanged() { current?.syncActivity() }`.
- Expose to subclasses: `protected fun currentUi(): SessionUi?` and a `currentKey(): String?`
(`current?.id ?: current?.cacheKey`, or a `NEW` sentinel when `current?.blank == true`).
### 2. Refactor `SessionSidePanelManager` onto the base
**Edit** `session/SessionSidePanelManager.kt`:
- Extend `SessionHost(project, root, …)` passing the existing defaults.
- Keep sidebar-specific: `component` (DataProvider), `panel`, history (`showHistory`,
`createHistory`, `back`), `history` injection, `defaultFocusedComponent`.
- Implement `present(ui)` by swapping into `component` (the current `show()` body).
- Override `activityChanged()` to call `super.activityChanged()` then
`(panel as? HistoryPanel)?.syncActivity()`.
- Behavior must be unchanged — `SessionSidePanelManagerTest` should pass without edits.
### 3. `WorktreeSessionListController`
**New** `worktree/WorktreeSessionListController.kt`:
- Ctor: `service: KiloSessionService`, `dir: String`, `cs: CoroutineScope`, telemetry hook.
- Holds a `CollectionListModel<SessionDto>` (or an EDT-marshalled row list). `reload()` launches
`service.list(dir)` and pushes rows to the model on the EDT (copy the `edt {}` helper from
`WorktreeController.kt`/`HistoryController.kt`).
- `delete(ids: List<String>, onDone: () -> Unit)``service.deleteSession(id, dir)` per id, then
`reload()`; marshal `onDone` to EDT.
- Telemetry: `"Worktree Session List Loaded"`, `"Worktree Session Deleted"`.
### 4. `WorktreeSessionEditorManager`
**New** `worktree/WorktreeSessionEditorManager.kt` — extends `SessionHost`, `Disposable`:
- Ctor: `parent: Disposable`, `project`, `worktree: Workspace` (root = worktree workspace),
`list: WorktreeSessionListController`, factory/timers defaults matching the sidebar. Register
under `parent`; obtain the SessionUi scope from `SessionUiFactory.scope()` and cancel on dispose.
- Holds `right: JPanel(BorderLayout)` as the presentation target; `val component get() = right`.
- `present(ui)`: `right.removeAll(); ui?.let { right.add(it, CENTER) }; revalidate/repaint`, then ask
the panel to select the list row for `currentKey()`.
- `onSessionsChanged()``list.reload()`.
- Override `activityChanged()``super` + detect blank→id transition of `current` (track
`lastCurrentId`); when the current session's id becomes newly non-null, call `list.reload()` so the
persisted row appears; otherwise just repaint rows (titles/activity) without RPC to avoid chatty
reloads during streaming.
- `deleteSessions(ids)`: confirm (`Messages.showYesNoDialog`, single vs multiple message); for each
id dispose any open `SessionUi` (`opened[id]`); delegate RPC delete to `list.delete(ids)`; if the
shown session was deleted, `openSession(next)` or `newSession()`.
- `start()` (called from the panel on first show): `list.reload()`, then show the most recent
existing session or `newSession()`.
### 5. Left panel + splitter: `WorktreeSessionEditorPanel`
**New** `worktree/WorktreeSessionEditorPanel.kt``BorderLayoutPanel`, `Disposable`, `UiDataProvider`:
- Build shell **eagerly, defer data**: create `OnePixelSplitter(false, 0.25f)`, left panel, right =
`manager.component`. Kick off `manager.start()` + `list.reload()` from an
`addHierarchyListener`/`addNotify` on first `SHOWING_CHANGED` (keeps headless construction cheap and
RPC-free, matching `SessionUi`/`HistoryPanel`).
- Left = `BorderLayoutPanel` with toolbar at NORTH and `ActiveList` at CENTER:
- **Toolbar:** an `ActionToolbar` from a `DefaultActionGroup` of two `AnAction`s
(`ActionUpdateThread.EDT`): New session (`AllIcons.General.Add``manager.newSession()`) and
Delete (`AllIcons.Actions.GC`/`General.Remove``manager.deleteSessions(activeList.selectedKeys())`,
enabled only when the selection is non-empty and deletable).
- **ActiveList:** `ActiveList(empty, cfg = ActiveListConfig(EQUAL, selection = MULTIPLE_INTERVAL_SELECTION),
placeholder = search hint, onCell = { key, id -> if (id == DELETE) manager.deleteSessions(listOf(key)) },
onClick = { row -> open its session }, onSelect = { updateToolbarEnablement() })`. Rows are a
`WorktreeSessionRow(ActiveListItem)`: `key = session.id` (or `NEW`), `title = session.title` or
"New session"/"Untitled session", `cells = [ delete cell (AllIcons.Actions.GC, iconOnly) ]`
(omit delete for the synthetic new row).
- Rebuild rows from `list.model` on model changes (`ListDataListener` → `activeList.update(rows,
PreserveNoScroll)`), prepending a synthetic "new session" row when `manager` current is blank.
Select the row matching `manager.currentKey()` after `present`.
- Provide `SessionManager.KEY` = manager and `SessionManager.WORKSPACE_KEY` = worktree workspace via
`uiDataSnapshot`.
- Bind theme via `LafManagerListener.TOPIC` (copy `AgentManagerPanel.bindTheme`).
### 6. `ActiveList` additive API
**Edit** `ui/list/ActiveList.kt`:
- Add ctor param `onSelect: (() -> Unit)? = null`; wire `view.onSelect = onSelect` in `init`.
- Add `@RequiresEdt fun selectedKeys(): List<String> = view.selectedKeys()` and
`selectedItems(): List<ActiveListItem> = view.selectedItems()` passthroughs.
(No behavior change for existing `AgentManagerPanel` usage.)
### 7. Wire the editor kind
**Edit** `worktree/WorktreeSessionEditorKind.kt`:
- In `createContent`, read `params["path"]`, resolve `workspace = service<KiloWorkspaceService>()
.workspace(path)`, build `WorktreeSessionListController` + `WorktreeSessionEditorManager` (parent =
`parent`) + `WorktreeSessionEditorPanel`, and return it. Keep returning an empty
`BorderLayoutPanel()` only when `path` is blank.
- Implement `preferredFocus(component)` to focus the left list's search/toolbar (optional).
### 8. i18n strings
**Edit** `frontend/src/main/resources/messages/KiloBundle.properties` (after the worktree block,
~line 323): `worktree.session.list.empty`, `worktree.session.list.search.placeholder`,
`worktree.session.new.action`, `worktree.session.delete.action`,
`worktree.session.delete.confirm.title`, `worktree.session.delete.confirm.message` (with `{0}`),
`worktree.session.delete.confirm.message.multiple` (with `{0}`), `worktree.session.new`,
`worktree.session.untitled`. Use `KiloBundle.message(...)` everywhere.
### 9. Tests (`frontend/src/test/.../agentManager/`, `BasePlatformTestCase`)
- **`WorktreeSessionEditorManagerTest`** (fakes: `FakeSessionRpcApi`, `KiloWorkspaceService` +
`FakeWorkspaceRpcApi`, `KiloAppService` + `FakeAppRpcApi`, `TestUiTimers`, `TestCoroutines` — mirror
`SessionSidePanelManagerTest` setup): `newSession()` shows a blank SessionUi on the right;
`openSession(Local(id))` shows it; `deleteSessions` disposes the open UI, calls RPC delete, and
re-presents the next/new session; list reflects `sessions.list(dir)`.
- **`WorktreeSessionEditorPanelTest`**: splitter proportion ≈ 0.25 and left/right children; left has
toolbar (+ / delete) + search + list; right hosts `manager.component`; `+` creates a session;
per-row delete cell and multi-select toolbar delete both trigger confirm/delete; provides
`SessionManager.KEY`/`WORKSPACE_KEY`. Drive first-show work by attaching to a `JFrame` (triggers
`addNotify`/`SHOWING_CHANGED`) rather than adding a test-only method.
- **`SessionSidePanelManagerTest`**: must still pass unchanged after the base extraction.
- **`AgentManagerPanelTest`**: the two cases that open the worktree editor via `KiloVfsManager` must
still pass. Because `createContent` builds the shell eagerly but defers RPC/session creation to
first show, headless construction stays cheap; verify `openFiles` assertions still hold and add
session RPC fakes only if `FileEditorManager.openFile` forces `getComponent()` in the harness.
## Risks / notes
- **Headless RPC:** ensure no RPC or SessionUi creation happens at panel construction — defer to
first show. `WorktreeSessionListController.reload()` must `try/catch` inside its `launch` so a
failed `KiloSessionRpcApi.getInstance()` in tests is logged, not thrown.
- **Chatty reloads:** `activityChanged()` fires frequently during streaming; only call `list.reload()`
on structural changes (new/deleted session, blank→id), and repaint rows for title/activity updates.
- **Global `_sessions` flow:** `KiloSessionService.list(dir)` overwrites a shared StateFlow; use the
returned DTO directly (as `HistoryController` does) and accept that the sidebar's convenience flow
may reflect the last-listed directory.
- **Worktree paths:** `WorktreeDto.path` comes from the backend git subprocess (real host path), so it
is usable directly as the workspace directory without `resolveProjectDirectory`.
- **`SessionManager` slash actions:** `SessionUi` calls `manager.showHistory()` for the "sessions"
slash command. In the editor, override it to focus/refresh the left list (no history stack).
## Validation
From `packages/kilo-jetbrains/`:
- `./gradlew typecheck`
- `./gradlew test` (or targeted: the new manager/panel tests plus `SessionSidePanelManagerTest`,
`AgentManagerPanelTest`, `WorktreeSessionEditorKindTest`).
- Manual smoke (`./gradlew runIde`): open Agent Manager → click a worktree → editor shows a 25% list
on the left and a session on the right; `+` adds a session; per-row and toolbar (multi-select)
delete remove sessions; search filters; deleting the shown session falls back to another/new one.
## Out of scope
Cloud sessions, rename UI, session grouping/sections, drag-reorder, PR/setup-script integration, and
`.kilo/agent-manager.json` persistence.
@@ -1,122 +1,105 @@
# Plan: IntelliJ-style open/focus + inactive selection for the worktree & session lists
# Plan: Worktree session rows — full visual parity with History rows
## Goal
Make the Agent Manager **worktree list** and the **session list inside a worktree** behave like the IntelliJ Project view:
Make the session list inside a worktree editor (`WorktreeSessionEditorPanel`) render with **full visual parity** to the History list rows:
- **Single click** → open the target but **do not** move focus (focus stays on the list).
- **Double click** → open **and** move focus to the editor / session.
- **Enter** → open; focus is decided by the IntelliJ advanced-settings flag
`edit.source.on.enter.key.request.focus.in.editor` (platform default `true`), exactly like the platform's `EditSourceOnEnterKeyHandler`.
- **F4** → open **and** move focus (mirrors `EditSource` = `BaseNavigateToSourceAction(true)`).
- A **selected-but-unfocused** row draws the standard platform **inactive (muted) selection background** (like the Project view), using platform colors.
- When the list is **not the focused/active selection**, **non-permanent action cells** (e.g. delete) are **hidden**. Permanent cells (`alwaysVisible`) stay. This must live in the shared base classes, not be duplicated per list.
- Same activity **tags** (`RUNNING`, `PLAN`, `QUESTION`, `PERMISSION`, `LOGIN_REQUIRED`) — identical chip text, style, and glyph.
- Same relative **time ago** on the right (e.g. "3h ago").
- Same **date section headers** (Today / Yesterday / This week / …).
- **Title only** — no leading branch icon, no directory subtitle.
- Keep the recently added open/focus/selection behavior and the delete-on-focused-selection cell (do **not** revert the "no delete icon when unfocused" behavior — that was an intentional divergence from History requested earlier).
Achieve this by **reusing shared helpers** (`SessionActivityKind`, `HistoryTime`, `LocalHistoryItem`) plus one small additive extension to the shared `ActiveList` renderer (a trailing text column). Do **not** duplicate `HistoryRenderer`, and do **not** move the worktree session list onto the History `HistoryModel`/`HistoryRenderer` stack (that would lose the shared `ActiveList` open/focus behavior and add duplication).
## Scope / boundaries
- All edits are under `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/...` and its tests. This is Kilo-owned code (no upstream opencode presence), so **no `kilocode_change` markers are required**.
- Do **not** change the main sidebar session behavior (it keeps focus-on-open via the default `focus = true`).
- Do **not** introduce Kotlin UI DSL / Compose / JCEF. Use existing platform APIs and `UiStyle`.
- Kilo-owned code under `packages/kilo-jetbrains/frontend/...`; no upstream opencode presence **no `kilocode_change` markers**.
- Do not change History behavior. Do not change the Agent Manager **worktree** list (top-level) rows.
- The shared `ActiveList` renderer change must be additive and default-off so settings/history-adjacent lists are visually unchanged.
## Key findings (verified in code)
- Both lists use the shared `ActiveList` component:
- Worktree list: `frontend/.../agentManager/AgentManagerPanel.kt` (`onClick = open`, no `onActivate`).
- Session list: `frontend/.../agentManager/worktree/WorktreeSessionEditorPanel.kt` (`onClick = open`).
- Shared list internals:
- `frontend/.../ui/list/ActiveList.kt` (public wrapper, ctor `onClick`/`onActivate`/`onCell`).
- `frontend/.../ui/list/ActiveListView.kt` (`JBList` host: mouse + Enter handling in `init`; `primary()`/`activate()`).
- `frontend/.../ui/list/ActiveListRenderer.kt` (renderer; computes `active`, calls `wrap.update(...)` and `syncCells(...)`).
- `frontend/.../session/ui/PickerRow.kt` (`SelectablePanel`; `update(list, selected, focused)` already sets `selectionColor = if (selected) UIUtil.getListBackground(true, focused) else null`).
- Renderer today (`ActiveListRenderer.getListCellRendererComponent`):
- `active = selected && (focused || list.hasFocus() || (list as? ActiveListActive)?.active() == true)`
- `wrap.update(list, active, active || focused)`**no selection background at all when unfocused** (the bug behind the "no highlight" complaint).
- `syncCells(value, active && list.isEnabled, list.isEnabled)` → non-permanent cells already hidden when unfocused (requirement already satisfied; must be preserved).
- Existing tests already lock in the cell-hiding contract: `SettingsListViewTest``test in-place action cells are hidden on unfocused selected row`, `test always visible action cells stay on unfocused row`, `test active popup paints selected row as active without focus`, `test unfocused selected row is not painted as active` (asserts `desc.foreground == UiStyle.Colors.weak()`).
- Session open focus path: `WorktreeSessionEditorPanel.open``WorktreeSessionEditorManager.openSession(ref)``SessionHost.openSession(ref)``show(ui)`**always** `focus(ui.defaultFocusedComponent)`.
- Worktree open focus path: `AgentManagerPanel.open``KiloVfsManager.open(kind, params, focus = true)` (already supports a `focus` param → `FileEditorManager.openFile(file, focus)`).
- IntelliJ references (from `$INTELLIJ_REPO`):
- `EditSourceAction` extends `BaseNavigateToSourceAction(true)`; keymap `$default.xml` binds `EditSource``F4` (focus = true).
- `EditSourceOnEnterKeyHandler` reads `AdvancedSettings.getBoolean("edit.source.on.enter.key.request.focus.in.editor")`; `PlatformExtensions.xml` registers it with `default="true"`.
- History rows: `HistoryRenderer` (`session/history/HistoryListRenderer.kt`) renders `title` + `BadgeLabel(FilledBadgeIcon(kind.label(), kind.style()))` + relative `time` + delete-on-selection, with `GroupHeaderSeparator` date sections. Time color = `if (selected) fg else UIUtil.getContextHelpForeground()`.
- Worktree session rows: shared `ActiveList` with `SessionRow: ActiveListItem` (`agentManager/worktree/WorktreeSessionEditorPanel.kt`) currently sets `icon = WorktreeIcons.branch`, `description = session.directory`, a delete cell, and no badges/time/section.
- Shared `ActiveList` already renders `ActiveListItem.badges` via the **same** `FilledBadgeIcon` History uses (`ui/list/ActiveListRenderer.kt` `syncBadges`), and already supports `ActiveListItem.section` headers (`activeListSectionTitle`). It has **no trailing time column**.
- Reusable, no duplication needed:
- `SessionActivityKind.label()` / `.style()` (`session/SessionActivityKind.kt`) — identical chips.
- `HistoryTime.relative/section/title/sorted` (`session/history/HistoryTime.kt`) — `internal`, visible across the frontend module.
- `LocalHistoryItem(session)` (`session/history/HistoryItem.kt`) — public wrapper turning a `SessionDto` into a `HistoryItem`, so `HistoryTime.*` works directly on worktree sessions.
- `UiStyle.Colors.weak() == UIUtil.getContextHelpForeground()`, so the shared renderer's existing `weak` color matches History's time color exactly.
- Activity plumbing already exists: `WorktreeSessionEditorManager` (a `SessionHost`) exposes `activity(): Map<String, SessionActivityKind>` (base `KiloSessionService` RUNNING + live opened-UI kinds), and `activityChanged()``onListChanged` → panel `sync()`. Rebuilding rows in `sync()` refreshes tags/time live.
## Decisions
1. **Keyboard mapping matches IntelliJ exactly.** Enter uses the advanced-settings flag `edit.source.on.enter.key.request.focus.in.editor` (default true) to decide focus. F4 always focuses. (Confirmed with user.)
2. **Add an explicit navigation handler** `onOpen: ((ActiveListItem, focus: Boolean) -> Unit)?` to `ActiveList`/`ActiveListView`, distinct from the settings "primary/cell" model. When `onOpen` is set it drives click / double-click / Enter / F4. When it's null (settings lists), current behavior is unchanged. This avoids overloading `primary()` (which for the worktree row would wrongly fall through to the delete cell).
3. **Inactive selection + cell gating are fixed once in the shared renderer** (`ActiveListRenderer` + `PickerRow`), benefiting every `ActiveList` consumer (settings, history, worktree, session).
4. No `SessionManager` interface signature churn: add the focus-aware open as a new method on `SessionHost` (Kilo-owned); the existing 1-arg `openSession(ref)` delegates with `focus = true`.
1. **Full parity via `ActiveList` config + one additive renderer field** (chosen by user). Reuse `SessionActivityKind` for tags and `HistoryTime` (via `LocalHistoryItem`) for time/section/sort. Add a reusable `trailing` text column to `ActiveList`.
2. **Do not unify renderers.** Keep two renderers; parity comes from shared data helpers + identical `FilledBadgeIcon`, so chips/time strings are identical without duplicating rendering logic.
3. **Keep delete-on-focused-selection** (ActiveList behavior). This intentionally differs from History (which shows delete on any selection) per the earlier requirement.
## Implementation tasks (ordered)
### 1. Shared renderer: paint inactive selection; keep cells focus-gated
File: `frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveListRenderer.kt`
- In `getListCellRendererComponent`, keep computing `active` as today.
- Change the selection painting so the background is drawn whenever the row is `selected`, muted when not active:
- Replace `wrap.update(list, active, active || focused)` with `wrap.update(list, selected, active)`.
- `PickerRow.update(list, selected=true, focused=false)` `UIUtil.getListBackground(true, false)` (muted).
- `PickerRow.update(list, selected=true, focused=true)``UIUtil.getListBackground(true, true)` (bright).
- Leave `fg`, `weak`, and `syncCells(value, active && list.isEnabled, list.isEnabled)` unchanged so:
- description foreground stays `UiStyle.Colors.weak()` on an unfocused selected row (keeps `test unfocused selected row is not painted as active` green), and
- non-permanent action cells stay hidden unless the row is the active focused selection (requirement #5, already covered by `SettingsListViewTest`).
- No change needed in `PickerRow.kt` (it already supports muted vs bright).
### 1. Shared: add a reusable trailing text column to `ActiveList`
Files: `ui/list/ActiveListModel.kt`, `ui/list/ActiveListRenderer.kt`
- `ActiveListModel.kt`: add `val trailing: String? get() = null` to the `ActiveListItem` interface (right-aligned secondary text such as relative time). Document it.
- `ActiveListRenderer.kt`:
- Add a right-aligned `JBLabel` (call it `trailing`) placed in the EAST region **before** the action cells. Simplest: wrap the existing `cellPane` and the new trailing label in a horizontal `Stack` (e.g. `Stack.horizontal(UiStyle.Gap.md()).next(trailingPane).next(cellPane)`) and put that in `row` `BorderLayout.EAST`; register both with `UiStyle.Components.transparent(...)`.
- In `getListCellRendererComponent`: set `trailing.text = value.trailing.orEmpty()`, `trailing.isVisible = !value.trailing.isNullOrBlank()`, `trailing.foreground = weak` (matches History; `weak = if (active) fg else UiStyle.Colors.weak()`).
- The trailing label is a plain `JBLabel`, not an `ActiveListActionCell`, so it does not affect `activeListCellBounds`/hit-testing.
- Default `trailing = null` → hidden → **no visual change** for existing consumers (settings, top-level worktree list).
### 2. Shared list: add focus-aware `onOpen` and IntelliJ key bindings
Files: `frontend/src/main/kotlin/ai/kilocode/client/ui/list/ActiveList.kt`, `ActiveListView.kt`
- Add ctor param `onOpen: ((ActiveListItem, focus: Boolean) -> Unit)? = null` to both `ActiveList` and `ActiveListView`; pass through.
- In `ActiveListView.init` mouse handler (`mouseClicked`):
- `clickCount == 1` and not a cell hit (`hit.id == null`): if `onOpen != null` call `onOpen(item, false)`, else fall back to existing `onClick`.
- `clickCount == 2` and not a cell hit: if `onOpen != null` call `onOpen(item, true)`, else existing `activate(item)`.
- Enter key registration (currently `{ primary() }`): route through a small helper — if `onOpen != null`, `list.selectedValue?.let { onOpen(it, enterRequestsFocus()) }`, else `primary()`.
- `enterRequestsFocus()` = `AdvancedSettings.getBoolean("edit.source.on.enter.key.request.focus.in.editor")` (`com.intellij.openapi.options.advanced.AdvancedSettings`).
- Add a new F4 key binding on `list` (`KeyStroke.getKeyStroke(KeyEvent.VK_F4, 0)`, `WHEN_FOCUSED`): if `onOpen != null`, `list.selectedValue?.let { onOpen(it, true) }`; otherwise no-op (leave platform default for non-navigation lists).
- Keep `mousePressed` requesting focus on the list (it focuses the list, not the editor — correct for single-click "open without focus").
- Optional cleanup: `onActivate` ctor param is never set by any caller; leave as-is to minimize risk (or remove in a follow-up).
### 2. Worktree session rows: configure like History
File: `agentManager/worktree/WorktreeSessionEditorPanel.kt`
- List `cfg`: set `description = false` (hide the directory subtitle). Keep `ActiveListRowHeight.EQUAL` and `MULTIPLE_INTERVAL_SELECTION`.
- `SessionRow` (`data class`) changes:
- Remove leading icon: drop `override val icon = WorktreeIcons.branch` (leave null → no glyph).
- Remove `description` override (directory) — no longer shown.
- Add `kind: SessionActivityKind?` field (passed in from `sync()`).
- `override val badges` = `listOfNotNull(kind?.let { ActiveListBadge(it.label(), it.style()) })`.
- `override val trailing` = `HistoryTime.relative(LocalHistoryItem(session))`.
- `override val section` = `HistoryTime.title(HistoryTime.section(LocalHistoryItem(session)))`.
- Keep `title`, `tooltip`, `search`, and the delete `cells` as-is.
- `sync()`:
- Sort the sessions the same way History does: `HistoryTime.sorted(sessions.map { LocalHistoryItem(it) })` then map back to `SessionDto`, or sort `SessionDto`s by `time.updated` desc with the same tiebreakers.
- Read the activity map once: `val kinds = manager.activity()` and build each `SessionRow(session, kinds[session.id])`.
- Keep `NewRow` pinned at index 0 when pending/new; `NewRow` keeps defaults (`badges` empty, `trailing`/`section` null) so it renders as a plain top row with no date header.
- `NewRow`: leave as a plain title row (defaults). Since its `section` is null and the first real row's section differs, the first date header renders directly under the New row (acceptable).
### 3. Thread focus through the session open path
File: `frontend/src/main/kotlin/ai/kilocode/client/session/SessionHost.kt`
- Add `show(ui: SessionUi, focus: Boolean = true)`; only call `focus(ui.defaultFocusedComponent)` when `focus`. Update the single existing `show(ui)` call site in `newSession()` to keep `focus = true` (default).
- Refactor `openSession`: move the current body into a new open method `open fun openSession(ref: SessionRef, focus: Boolean)`, ending in `show(ui, focus)`. Keep `override fun openSession(ref: SessionRef) = openSession(ref, focus = true)` (satisfies `SessionManager`). Internal callers that use the 1-arg keep focus = true.
### 3. Reuse check (no new logic)
- Confirm `HistoryTime` (`internal`) and `LocalHistoryItem` (public) are importable from `ai.kilocode.client.agentManager.worktree` (same `frontend` module → yes).
- Confirm `SessionActivityKind.style()` maps `RUNNING → Alert`, others `Primary` (identical chips to History).
### 4. Session list panel: pass focus from gestures
File: `frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeSessionEditorPanel.kt`
- Change `open(row)``open(row, focus: Boolean)`; the NEW row branch still calls `manager.newSession()` (focus = true), otherwise `manager.openSession(SessionRef.Local(item), focus)`.
- Replace `onClick = { row -> open(row) }` with `onOpen = { row, focus -> open(row, focus) }`.
## Tests
### 5. Worktree list panel: pass focus from gestures
File: `frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt`
- Change `open(item)``open(item, focus: Boolean)` calling `KiloVfsManager.open(WorktreeSessionEditorKind.ID, worktreeSessionParams(item), focus)`.
- Replace `onClick = { row -> ... open(item) }` with `onOpen = { row, focus -> (row as? WorktreeRow)?.dto?.let { open(it, focus) } }`.
- Leave the delete-cell `onCell` handler and the `onSelect`/`focusList()` create-flow untouched.
File: `frontend/src/test/.../agentManager/worktree/WorktreeSessionEditorPanelTest.kt`
- Have `FakeManager` override `activity()` to return a controlled map (e.g. `mapOf("ses_1" to SessionActivityKind.RUNNING)`); this avoids depending on the project `KiloSessionService` instance.
- Assert for a rendered `SessionRow`:
- `badges` == `listOf(ActiveListBadge(SessionActivityKind.RUNNING.label(), SessionActivityKind.RUNNING.style()))`.
- `icon == null` and `description == null` (no branch glyph, no directory subtitle).
- `trailing == HistoryTime.relative(LocalHistoryItem(session))`.
- `section == HistoryTime.title(HistoryTime.section(LocalHistoryItem(session)))`.
- Assert rows are ordered by updated-desc (same as `HistoryTime.sorted`) and the `NewRow` stays at index 0 when pending.
- Keep existing tests green (single/double click, Enter/F4 focus, delete).
### 6. Tests
- `frontend/src/test/.../settings/base/SettingsListViewTest.kt` (or a new `ActiveListViewTest` in the same package):
- Add: unfocused selected row paints muted selection — assert the `PickerRow`'s `selectionColor == UIUtil.getListBackground(true, false)` after `getListCellRendererComponent(list, row, 0, true, false)`.
- Add: focused selected row paints bright — `... == UIUtil.getListBackground(true, true)` (rendered with `focused = true` or `ActiveListActive.active() == true`).
- Keep existing cell-visibility tests (they already assert requirement #5).
- `frontend/src/test/.../agentManager/worktree/WorktreeSessionEditorPanelTest.kt`:
- Update `FakeManager` to override the new `openSession(ref: SessionRef, focus: Boolean)` (record `focus`) instead of `openSession(ref)`.
- Update `test row click opens session` to also assert `focus == false`.
- Add a double-click test (`clickCount = 2`) asserting `focus == true`.
- Add Enter and F4 key tests: invoke the list's registered action via `list.getActionForKeyStroke(KeyStroke...)` (or dispatch `KeyEvent`s) after selecting a row; assert F4 → `focus == true`, Enter → `focus == AdvancedSettings.getBoolean("edit.source.on.enter.key.request.focus.in.editor")`.
- Add a new `AgentManagerPanel` open/focus test (new file) or an `ActiveListView`-level test proving `onOpen` receives `focus = false` on single-click / `true` on double-click / `true` on F4 / flag-driven on Enter. Prefer the `ActiveListView`-level test since it covers the shared behavior for both lists in one place and avoids the `KiloVfsManager` project-service seam.
File: `frontend/src/test/.../settings/base/SettingsListViewTest.kt`
- Add: a row with `trailing = "3h ago"` renders a visible right-aligned label with that text; a row with `trailing = null` hides it. (Locks the additive shared-renderer behavior and guards other consumers.)
## Risks / edge cases
- **Blast radius of the renderer change**: `ActiveListRenderer` is shared by settings and history lists. After the change they will show a muted selection when unfocused (previously nothing). This is the platform-standard behavior and matches the UI guidelines; verify `SettingsListViewTest` / history tests still pass and adjust assertions only if they specifically asserted "no background when unfocused" (none currently do). `PickerPopup` uses its own renderer, so it is unaffected.
- **`primary()` fallback bug**: today Enter on a worktree row falls through `primary()` to the first enabled cell (delete) and would pop the delete confirmation. Routing Enter through `onOpen` for navigation lists removes this; confirm no navigation list relies on Enter→cell.
- **F4 binding in settings dialogs**: F4 is only bound when `onOpen != null` (navigation lists), so settings/dialog `EditSource` behavior is unchanged.
- **`openFile(file, false)`** opens/selects the worktree tab without transferring focus; confirm the tab still becomes visible (expected) while focus remains on the Agent Manager list.
- **New-session / delete-next-session flows** keep `focus = true` (creating or auto-opening a session should focus its prompt) — verify this matches desired UX.
- **Shared renderer blast radius**: `trailing` defaults null → existing lists unchanged; verify `SettingsListViewTest` and any history-adjacent tests stay green.
- **Section headers now appear** in the worktree session list (new). This is intended for full parity; if undesired later, return `section = null` from `SessionRow`.
- **Row inset differences**: `ActiveListRenderer` uses `empty(md, 0, md, pad)`; `HistoryRenderer` uses `empty(lg, lg, lg, lg)`. Chips and time strings are identical, but padding differs slightly. If exact padding parity is required, align `ActiveListRenderer.row.border` for this list — treat as optional polish, and confirm it doesn't regress other `ActiveList` consumers (prefer leaving shared insets unchanged).
- **Live time refresh**: relative time is recomputed on `sync()` (activity/list changes), same as History recomputes on repaint; it won't tick every minute on its own. Acceptable / matches History.
- **Delete visibility** stays gated on focused selection (ActiveList) — intentionally different from History; do not change.
## Validation
From `packages/kilo-jetbrains/`:
- `./gradlew :frontend:test --tests ai.kilocode.client.agentManager.worktree.WorktreeSessionEditorPanelTest --tests ai.kilocode.client.settings.base.SettingsListViewTest`
- `./gradlew typecheck`
- `./gradlew test` (or targeted: `SettingsListViewTest`, `WorktreeSessionEditorPanelTest`, and the new `ActiveListView`/renderer tests)
- Manual (optional) `./gradlew runIde`: in Agent Manager, single-click a worktree (tab opens, list keeps focus + bright selection), double-click (editor focuses, list shows muted selection), Enter (focus per advanced setting), F4 (focus). Repeat for the session list inside a worktree. Confirm the delete icon is absent whenever the list is unfocused and the muted selection is visible.
- Optional `./gradlew runIde`: worktree session rows show the same tag chips, "time ago", and date sections as History; single word title only, no branch icon/subtitle.
## Out of scope
- Main sidebar session list behavior (unchanged).
- Preview-tab semantics; we only toggle focus, not preview tabs.
- Removing the now-unused `onActivate`/`onClick` params (optional later cleanup).
- Migrating the worktree session list onto the History renderer/model stack.
- Changing History rows or the top-level worktree list.
- Auto-ticking relative time.
@@ -49,7 +49,7 @@ class AgentManagerPanel(
private val provider = WorktreeDeleteProvider()
private val list = ActiveList(
KiloBundle.message("worktree.empty"),
placeholder = KiloBundle.message("worktree.search.placeholder"),
showSearch = false,
onCell = { key, id ->
if (id != DELETE_CELL) return@ActiveList
val item = item(key) ?: return@ActiveList
@@ -49,6 +49,7 @@ open class WorktreeSessionEditorManager(
) : SessionHost(project, worktree, create, resolve, status, timers, request) {
private val right = JPanel(BorderLayout())
private var last: String? = null
private var pending = false
var onPresent: ((String?) -> Unit)? = null
var onListChanged: (() -> Unit)? = null
@@ -66,6 +67,20 @@ open class WorktreeSessionEditorManager(
}
}
@RequiresEdt
open fun hasPendingNew(): Boolean = pending
@RequiresEdt
override fun newSession() {
if (pending) return
pending = true
onListChanged?.invoke()
list.create { session ->
pending = false
if (session != null) openSession(SessionRef.Local(session)) else onListChanged?.invoke()
}
}
@RequiresEdt
override fun showHistory() {
list.reload()
@@ -1,11 +1,15 @@
package ai.kilocode.client.agentManager.worktree
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.SessionActivityKind
import ai.kilocode.client.session.SessionHost
import ai.kilocode.client.session.SessionManager
import ai.kilocode.client.session.SessionRef
import ai.kilocode.client.session.history.HistoryTime
import ai.kilocode.client.session.history.LocalHistoryItem
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.list.ActiveList
import ai.kilocode.client.ui.list.ActiveListBadge
import ai.kilocode.client.ui.list.ActiveListCell
import ai.kilocode.client.ui.list.ActiveListConfig
import ai.kilocode.client.ui.list.ActiveListItem
@@ -47,8 +51,12 @@ class WorktreeSessionEditorPanel(
private val delete = DeleteAction()
private val list = ActiveList(
KiloBundle.message("worktree.session.list.empty"),
cfg = ActiveListConfig(ActiveListRowHeight.EQUAL, selection = ListSelectionModel.MULTIPLE_INTERVAL_SELECTION),
placeholder = KiloBundle.message("worktree.session.list.search.placeholder"),
cfg = ActiveListConfig(
ActiveListRowHeight.EQUAL,
description = false,
selection = ListSelectionModel.MULTIPLE_INTERVAL_SELECTION,
),
showSearch = false,
onCell = { key, id -> if (id == DELETE_CELL) manager.deleteSessions(listOf(key)) },
onOpen = { row, focus -> open(row, focus) },
onSelect = { updateActions() },
@@ -126,10 +134,14 @@ class WorktreeSessionEditorPanel(
@RequiresEdt
private fun sync() {
val rows = mutableListOf<ActiveListItem>()
if (manager.currentKey() == SessionHost.NEW) rows += NewRow
rows += (0 until controller.model.size).map { SessionRow(controller.model.getElementAt(it)) }
val key = manager.currentKey()
val pending = manager.hasPendingNew()
val kinds = manager.activity()
if (pending || key == SessionHost.NEW) rows += NewRow
rows += HistoryTime.sorted((0 until controller.model.size).map { LocalHistoryItem(controller.model.getElementAt(it)) })
.map { SessionRow(it.session, kinds[it.id]) }
list.update(rows, ActiveListSelection.PreserveNoScroll)
select(manager.currentKey())
select(if (pending) SessionHost.NEW else key)
updateActions()
}
@@ -212,16 +224,17 @@ class WorktreeSessionEditorPanel(
private object NewRow : ActiveListItem {
override val key: String get() = SessionHost.NEW
override val title: String get() = KiloBundle.message("worktree.session.new")
override val icon = AllIcons.General.Add
}
private data class SessionRow(val session: SessionDto) : ActiveListItem {
private data class SessionRow(val session: SessionDto, val kind: SessionActivityKind?) : ActiveListItem {
private val item = LocalHistoryItem(session)
override val key: String get() = session.id
override val title: String get() = session.title.takeIf { it.isNotBlank() }
?: KiloBundle.message("worktree.session.untitled")
override val description: String get() = session.directory
override val tooltip: String get() = title
override val icon = WorktreeIcons.branch
override val badges: List<ActiveListBadge> get() = listOfNotNull(kind?.let { ActiveListBadge(it.label(), it.style()) })
override val trailing: String get() = HistoryTime.relative(item)
override val section: String get() = HistoryTime.title(HistoryTime.section(item))
override val search: String get() = listOf(session.title, session.id, session.directory).joinToString(" ")
override val cells: List<ActiveListCell>
get() = listOf(ActiveListCell(
@@ -33,6 +33,25 @@ class WorktreeSessionListController(
}
}
fun create(done: (SessionDto?) -> Unit) {
cs.launch {
try {
val session = service.create(dir)
edt {
val keep = (0 until model.size)
.map { model.getElementAt(it) }
.filter { it.id != session.id }
model.replaceAll(listOf(session) + keep)
capture("Worktree Session Created", mapOf("sessionId" to session.id))
done(session)
}
} catch (e: Exception) {
LOG.warn("worktree session create failed dir=$dir message=${e.message}", e)
edt { done(null) }
}
}
}
fun delete(ids: List<String>, done: () -> Unit) {
val active = ids.distinct().filter { it.isNotBlank() }
if (active.isEmpty()) {
@@ -45,8 +45,9 @@ internal data class ActiveListCell(
/**
* A row in an [ActiveList]. Carries the display contract shared by settings pages, the worktree
* list, and the session history stack: a leading icon, a bold title with an inline [note], a
* secondary [description] line, inline [badges], and action [cells]. Action cells are shown only
* for the active focused selection unless [ActiveListCell.alwaysVisible] is true.
* secondary [description] line, inline [badges], optional right-aligned [trailing] text, and
* action [cells]. Action cells are shown only for the active focused selection unless
* [ActiveListCell.alwaysVisible] is true.
*/
internal interface ActiveListItem {
val key: String
@@ -59,6 +60,8 @@ internal interface ActiveListItem {
val icon: Icon? get() = null
val section: String? get() = null
val badges: List<ActiveListBadge> get() = emptyList()
/** Right-aligned secondary text, such as a relative timestamp. */
val trailing: String? get() = null
val cells: List<ActiveListCell> get() = emptyList()
val disabled: Boolean get() = false
/** Extra text matched by the filter field in addition to [title]; null matches title only. */
@@ -35,23 +35,31 @@ internal class ActiveListRenderer(
private val mark = icon.align(HAlign.CENTER, VAlign.CENTER)
private val title = SimpleColoredComponent()
private val badges = Stack.horizontal()
private val header = Stack.horizontal(UiStyle.Gap.xs()).next(title).next(badges)
// Title in CENTER clips when the row is narrow; trailing tags in EAST keep their full
// preferred width. A squeezed row sacrifices the title text but never drops the tags.
private val header = JPanel(BorderLayout(UiStyle.Gap.xs(), 0)).apply {
add(title, BorderLayout.CENTER)
add(badges, BorderLayout.EAST)
}
private val desc = JBLabel()
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 trailPane = trail.align(HAlign.RIGHT, VAlign.CENTER)
private val cells = Stack.horizontal(activeListCellGap())
private val cellPane = cells.align(HAlign.RIGHT, VAlign.CENTER)
private val actions = Stack.horizontal(UiStyle.Gap.md()).next(trailPane).next(cellPane)
private val row = JPanel(BorderLayout(UiStyle.Gap.md(), 0)).apply {
add(mark, BorderLayout.WEST)
add(textPane, BorderLayout.CENTER)
add(cellPane, BorderLayout.EAST)
add(actions, BorderLayout.EAST)
}
private val wrap = PickerRow()
init {
isOpaque = true
top.isOpaque = true
UiStyle.Components.transparent(row, mark, icon, title, badges, header, text, textPane, desc, cells, cellPane)
UiStyle.Components.transparent(row, mark, icon, title, badges, header, text, textPane, desc, trail, trailPane, cells, cellPane, actions)
row.border = JBUI.Borders.empty(
UiStyle.Gap.md(),
0,
@@ -100,11 +108,16 @@ internal class ActiveListRenderer(
JBUI.Borders.empty()
}
desc.foreground = weak
val end = value.trailing.orEmpty()
trail.text = end
trail.isVisible = end.isNotBlank()
trail.foreground = weak
// In-place action buttons follow the selection highlight: only when the selection is
// visible (list focused, or an owned popup is active). An unfocused list hides them.
syncCells(value, active && list.isEnabled, list.isEnabled)
cellPane.isVisible = cells.isVisible
actions.isVisible = trail.isVisible || cellPane.isVisible
top.invalidate()
return this
}
@@ -17,6 +17,7 @@ import ai.kilocode.rpc.dto.WorktreeDto
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.components.service
import com.intellij.openapi.fileEditor.FileEditorManager
import com.intellij.ui.SearchTextField
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.ui.components.JBList
import com.intellij.util.ui.UIUtil
@@ -67,6 +68,13 @@ class AgentManagerPanelTest : BasePlatformTestCase() {
assertEquals(created.id, edt { (list.selectedValue as ActiveListItem).key })
}
fun `test panel hides worktree search field`() {
val controller = WorktreeController(service, "/test", coroutines.scope)
val panel = edt { AgentManagerPanel(testRootDisposable, controller) }
assertNull(edt { UIUtil.findComponentOfType(panel, SearchTextField::class.java) })
}
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")
rpc.listed += item
@@ -66,14 +66,17 @@ class WorktreeSessionEditorManagerTest : BasePlatformTestCase() {
}
}
fun `test new session shows a blank session on the right`() {
fun `test new session creates and opens a persisted session`() {
rpc.session = session("ses_new", updated = 4.0).copy(title = "New session")
val manager = manager()
edt { manager.newSession() }
flush()
val active = edt { manager.component.getComponent(0) as JPanel }
assertTrue(active is SessionUi)
assertEquals(listOf(DIR to null), created)
assertEquals(1, rpc.creates)
assertEquals(listOf(DIR to "ses_new"), created)
}
fun `test open session shows selected session`() {
@@ -99,6 +102,18 @@ class WorktreeSessionEditorManagerTest : BasePlatformTestCase() {
assertEquals(listOf(DIR to "ses_new"), created)
}
fun `test start creates a session when none are listed`() {
rpc.session = session("ses_new", updated = 4.0).copy(title = "New session")
val manager = manager()
edt { manager.start() }
flush()
assertTrue(rpc.lists.contains(DIR))
assertEquals(1, rpc.creates)
assertEquals(listOf(DIR to "ses_new"), created)
}
fun `test deleting shown session removes it and falls back to next session`() {
val first = session("ses_1", updated = 3.0)
val second = session("ses_2", updated = 2.0)
@@ -2,11 +2,16 @@ package ai.kilocode.client.agentManager.worktree
import ai.kilocode.client.app.KiloSessionService
import ai.kilocode.client.app.Workspace
import ai.kilocode.client.session.SessionActivityKind
import ai.kilocode.client.session.SessionManager
import ai.kilocode.client.session.SessionRef
import ai.kilocode.client.session.history.HistoryTime
import ai.kilocode.client.session.history.LocalHistoryItem
import ai.kilocode.client.testing.FakeSessionRpcApi
import ai.kilocode.client.testing.TestCoroutines
import ai.kilocode.client.testing.fire
import ai.kilocode.client.ui.list.ActiveListBadge
import ai.kilocode.client.ui.list.ActiveListItem
import ai.kilocode.rpc.dto.SessionDto
import ai.kilocode.rpc.dto.SessionTimeDto
import com.intellij.openapi.actionSystem.DataKey
@@ -21,6 +26,7 @@ import com.intellij.openapi.ui.TestDialog
import com.intellij.openapi.ui.TestDialogManager
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.ui.OnePixelSplitter
import com.intellij.ui.SearchTextField
import com.intellij.ui.components.JBList
import com.intellij.util.ui.UIUtil
import java.awt.Container
@@ -69,6 +75,7 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() {
assertTrue(buttons.contains("New session"))
assertTrue(buttons.contains("Delete session"))
assertNotNull(UIUtil.findComponentOfType(panel, JBList::class.java))
assertNull(UIUtil.findComponentOfType(panel, SearchTextField::class.java))
}
}
@@ -81,6 +88,45 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() {
assertEquals(1, manager.newCount)
}
fun `test pending new session appears in list`() {
manager.pending = true
edt { manager.onListChanged?.invoke() }
val list = edt { UIUtil.findComponentOfType(panel, JBList::class.java)!! }
assertEquals("New session", edt { (list.model.getElementAt(0) as ActiveListItem).title })
assertEquals("new", edt { (list.selectedValue as ActiveListItem).key })
}
fun `test session rows match history visuals`() {
manager.kinds = mapOf("ses_1" to SessionActivityKind.RUNNING)
val session = session("ses_1", nowSeconds())
rpc.listed += session
edt { controller.reload() }
flush()
val row = row("ses_1")
assertEquals("Session ses_1", row.title)
assertNull(row.icon)
assertNull(row.description)
assertEquals(listOf(ActiveListBadge(SessionActivityKind.RUNNING.label(), SessionActivityKind.RUNNING.style())), row.badges)
assertEquals(HistoryTime.relative(LocalHistoryItem(session)), row.trailing)
assertEquals(HistoryTime.title(HistoryTime.section(LocalHistoryItem(session))), row.section)
}
fun `test sessions sort by updated desc with new row pinned`() {
manager.pending = true
rpc.listed += session("ses_old", 1.0)
rpc.listed += session("ses_new", 2.0)
edt { controller.reload() }
flush()
val keys = rows().map { it.key }
assertEquals(listOf("new", "ses_new", "ses_old"), keys)
}
fun `test row click opens session`() {
rpc.listed += session("ses_1", 1.0)
edt { controller.reload() }
@@ -145,7 +191,7 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() {
}
pump()
assertEquals(listOf("ses_1", "ses_2"), manager.deleted)
assertEquals(listOf("ses_2", "ses_1"), manager.deleted)
}
fun `test panel provides session manager and workspace data`() {
@@ -166,6 +212,15 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() {
time = SessionTimeDto(created = 0.0, updated = updated),
)
private fun nowSeconds() = System.currentTimeMillis().toDouble() / 1000.0
private fun rows(): List<ActiveListItem> {
val view = edt { UIUtil.findComponentOfType(panel, JBList::class.java)!! }
return edt { (0 until view.model.size).map { view.model.getElementAt(it) as ActiveListItem } }
}
private fun row(key: String): ActiveListItem = rows().single { it.key == key }
private fun flush() = coroutines.drain(::pump)
private fun pump() {
@@ -212,10 +267,16 @@ class WorktreeSessionEditorPanelTest : BasePlatformTestCase() {
confirm = { _, _, _ -> true },
) {
var newCount = 0
var pending = false
var kinds = emptyMap<String, SessionActivityKind>()
val refs = mutableListOf<String>()
val focuses = mutableListOf<Boolean>()
val deleted = mutableListOf<String>()
override fun hasPendingNew(): Boolean = pending
override fun activity(): Map<String, SessionActivityKind> = kinds
override fun newSession() {
newCount++
}
@@ -2,8 +2,10 @@ package ai.kilocode.client.settings.base
import ai.kilocode.client.testing.fire
import ai.kilocode.client.session.ui.PickerRow
import ai.kilocode.client.ui.FilledBadgeIcon
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.list.ActiveListActionCell
import ai.kilocode.client.ui.list.ActiveListBadge
import ai.kilocode.client.ui.list.ActiveListActive
import ai.kilocode.client.ui.list.ActiveListCell
import ai.kilocode.client.ui.list.ActiveListConfig
@@ -157,6 +159,31 @@ class SettingsListViewTest : BasePlatformTestCase() {
}
}
fun `test narrow row squeezes title but keeps tags full width`() {
edt {
val row = object : ActiveListItem {
override val key = "with"
override val title = "A very long session title that cannot fit in a narrow row"
override val badges = listOf(ActiveListBadge("Running"))
}
val model = CollectionListModel<ActiveListItem>(listOf(row))
val list = JBList(model)
val renderer = ActiveListRenderer(model, ActiveListConfig.Equal)
renderer.getListCellRendererComponent(list, row, 0, true, true)
renderer.setSize(160, renderer.preferredSize.height)
layout(renderer)
val badge = components(renderer).filterIsInstance<JBLabel>()
.single { (it.icon as? FilledBadgeIcon)?.text == "Running" }
val title = components(renderer).filterIsInstance<SimpleColoredComponent>().single()
assertTrue(badge.isVisible)
assertTrue(badge.width >= badge.icon.iconWidth)
assertTrue(title.width < title.preferredSize.width)
}
}
fun `test renderer centers leading icon vertically in the row`() {
edt {
val row = object : ActiveListItem {
@@ -179,6 +206,33 @@ class SettingsListViewTest : BasePlatformTestCase() {
}
}
fun `test renderer shows optional trailing text`() {
edt {
val with = object : ActiveListItem {
override val key = "with"
override val title = "Alpha"
override val trailing = "3h ago"
}
val without = object : ActiveListItem {
override val key = "without"
override val title = "Beta"
}
val model = CollectionListModel<ActiveListItem>(listOf(with, without))
val list = JBList(model)
val renderer = ActiveListRenderer(model, ActiveListConfig.Equal)
renderer.getListCellRendererComponent(list, with, 0, true, true)
val trail = components(renderer).filterIsInstance<JBLabel>().single { it.text == "3h ago" }
assertTrue(trail.isVisible)
assertEquals(SwingConstants.RIGHT, trail.horizontalAlignment)
renderer.getListCellRendererComponent(list, without, 1, true, true)
assertTrue(components(renderer).filterIsInstance<JBLabel>().none { it.text == "3h ago" && it.isVisible })
}
}
fun `test title only config suppresses descriptions and tooltips`() {
edt {
val cfg = ActiveListConfig.Equal.copy(description = false)