mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-01 15:32:11 +08:00
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:
@@ -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.
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+15
@@ -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()
|
||||
|
||||
+22
-9
@@ -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(
|
||||
|
||||
+19
@@ -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()) {
|
||||
|
||||
+5
-2
@@ -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. */
|
||||
|
||||
+16
-3
@@ -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
|
||||
}
|
||||
|
||||
+8
@@ -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
|
||||
|
||||
+17
-2
@@ -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)
|
||||
|
||||
+62
-1
@@ -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++
|
||||
}
|
||||
|
||||
+54
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user