mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-29 03:44:06 +08:00
feat(agent-manager): replace sessions list with a per-project history button
Move the per-project SESSIONS list out of the Agent Manager sidebar and behind a per-project history button that opens the sessions view scoped to that project. Each session row offers direct actions to resume it in a new worktree or in the project's local tabs, and sessions are no longer part of the sidebar keyboard navigation order.
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Replace the per-project SESSIONS list in the Agent Manager sidebar with a per-project history button. The sessions view is now scoped to the clicked project and offers per-session actions to resume it in the project's local tabs or in a freshly created worktree.
|
||||
@@ -0,0 +1,196 @@
|
||||
# Plan: Replace the Agent Manager SESSIONS list with a per-project sessions button
|
||||
|
||||
Worktree: `/Users/marius/Documents/git/kilocode/.kilo/worktrees/hill-animal`
|
||||
All paths below are relative to `packages/kilo-vscode/`.
|
||||
|
||||
Related: https://github.com/Kilo-Org/kilocode/issues/12928
|
||||
|
||||
## Problem
|
||||
|
||||
The Agent Manager sidebar renders an inline `SESSIONS` section in both modes:
|
||||
|
||||
- Single project: `SidebarBody.tsx` renders `UnassignedSessionsSection` (bottom of the sidebar).
|
||||
- Multi project: `ProjectSidebarBody.tsx` renders the same section per expanded project (bottom of each project body).
|
||||
|
||||
Limitations:
|
||||
|
||||
- The section only lists root sessions (`worktreeId === null`). Sessions that live in a worktree are not reachable from it.
|
||||
- In multi-project mode every expanded project renders its own unbounded list, pushing other projects out of the viewport.
|
||||
- The inline list grows without a scroll container (`UnassignedSessionsSection` sets `am-section-grow` but the scroll container is the projects list, so the list can only push).
|
||||
|
||||
Goal: remove the inline SESSIONS element from both modes and behind a per-project button. Clicking the button does the same as `/sessions` in the Agent Manager (open the history view), but scoped to that project only, so sessions of other projects never appear.
|
||||
|
||||
## Current `/sessions` flow in Agent Manager (reference behavior)
|
||||
|
||||
`/sessions` is an action in `useSlashCommand.ts` that posts `{ type: "navigate", view: "history" }`. In the Agent Manager this hits `AgentManagerApp.tsx` handler `setHistory(true)`, which shows `HistoryView`.
|
||||
|
||||
`HistoryView` (src/components/history/HistoryView.tsx) has three tabs:
|
||||
|
||||
- Local: `SessionList` without a `sessionIds` filter, so it renders `session.sessions()` (the session context store).
|
||||
- Cloud: unchanged.
|
||||
- Worktree: only when the sidebar has a worktree selected; `SessionList` filtered to that worktree's session ids (`activeWorktreeSessionIds`).
|
||||
|
||||
Picking a session (`HistoryView onSelectSession`, `AgentManagerApp.tsx`):
|
||||
|
||||
1. `addSessionToCurrentWorktree(sid)`: when a worktree is selected and the session is not owned by any worktree, the session is attached to the selected worktree (`agentManager.addSessionToWorktree`).
|
||||
2. When the session is already owned by a worktree: navigate to its owning worktree (`worktreeSessionIds().has(id)`).
|
||||
3. When it is in the project's local tabs: select it in the local context.
|
||||
4. Otherwise: `openLocally(id)` (adds it to the project's local tabs, replacing a pending New Session draft).
|
||||
|
||||
`session.sessions()` is populated by `loadSessions`, which the extension scopes to a single project (the active project's `projectQualifier`). It structurally cannot contain another project's sessions. Per-project session data arrives over a separate pipeline: `agentManager.projectSessions` -> `createProjectSessionsLive()` in the webview, which contains every session of a project (root and worktree sessions, tagged with `worktreeId: string | null`).
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Button per project, same behavior as `/sessions`
|
||||
|
||||
- Single project: add a history `IconButton` to the WORKTREES header actions (`WorktreeSectionActions.tsx`, next to search / new worktree). Click -> `setHistory(true)` (identical to `/sessions`).
|
||||
- Multi project: add a history `IconButton` to each project row header in `ProjectsSection.tsx` (next to the remove X), so every project has exactly one button, also when the project is collapsed. Click -> activate that project via `agentManager.activateSelection` with target `{ projectId, kind: "local" }` (no restore), then `setHistory(true)` with the project-scoped filter.
|
||||
|
||||
Reason for activating the project first: `HistoryView`/`SessionList` render `session.sessions()`, which is only populated for the active project. Activating the target project first is the only way to reuse the existing `/sessions` machinery without writing a bespoke list. When the user clicks back, they are in that project, which matches clicking the project row today.
|
||||
|
||||
Icon: `Icon name="history"` (kilo-ui falls back to the upstream icon set; already used by `WelcomeEmptyState.tsx`). Label: reuse the existing `session.showHistory` key, already translated in every locale.
|
||||
|
||||
### 2. Project-scoped filtering
|
||||
|
||||
Track `historyProject` (signal, `string | undefined`) in `AgentManagerContent`:
|
||||
|
||||
- `undefined` = legacy `/sessions` flow (unchanged behavior, Worktree tab stays).
|
||||
- project id = project-scoped history:
|
||||
- Local tab: `HistoryView` gets a new optional `sessionIds` prop; `SessionList` already accepts `sessionIds`, so the Local tab renders only the filter set.
|
||||
- Filter set = ids of `projectSessionsLive()[pid]` (all root + worktree sessions of that project, including live-store overlays once the project is active), filtered by `isKnownRootSession`.
|
||||
- Worktree tab: hidden while `historyProject` is set. All project sessions are already in the Local tab; a Worktree tab could show a different project's selected worktree and pollute the scope.
|
||||
- Cloud tab: unchanged.
|
||||
- `historyProject` resets when history closes (Back button, session picked, `onBack`).
|
||||
|
||||
Session list membership uses `projectSessionsLive()[pid]`, which the extension pushes per project at project init. If it is missing, the filter set is empty (no other project's sessions can bleed in).
|
||||
|
||||
### 3. Where a picked session starts, and the start-location choice
|
||||
|
||||
When `historyProject` is set, picking a session uses the same routing as `/sessions` in that project (the project is active after step 1, so the existing `onSelectSession` handler in `AgentManagerApp.tsx` operates on the right project):
|
||||
|
||||
- Row click (default, same action as the old SESSIONS row click): session owned by a worktree stays bound to that worktree and jumps to it; root sessions open in the project's local tabs (`openLocally`), replacing a pending New Session draft.
|
||||
- Row menu (three-dot, avatar/hover): each row also offers a start-location choice, shown in the sessions view:
|
||||
- **Start in a new worktree**: the existing promote pipeline (`agentManager.promoteSession`), which creates a fresh worktree in the project, runs its setup script, moves the session into it, and opens it there.
|
||||
- **Open in project local tabs** (worktree-bound rows only; root rows just open locally): new message `agentManager.openSessionLocally` moves the session back to the project root (state `moveSession(sessionId, null)` + root route registration + state push) and activates it in the project's local tabs.
|
||||
- Attach-to-selected-worktree (`addSessionToCurrentWorktree`) only applies in the legacy `/sessions` flow, where the user opened history while a worktree was selected. Because the button activates the project with a `local` target first, the project-scoped flow never rebinds a root session to a worktree.
|
||||
|
||||
This is the deliberate behavior difference: the button's list is project-scoped and origin-driven; the in-chat `/sessions` keeps the continue-in-worktree semantics.
|
||||
|
||||
#### Removed worktree edge case
|
||||
|
||||
When a worktree is deleted, the flow in `provider-lifecycle.ts` already de-syncs its sessions: `state.removeWorktree(worktreeId)` deletes the worktree and every session bound to it from the managed state (`WorktreeStateManager.removeWorktree` removes `sessions[worktreeId]` entries and returns them as orphaned), and the caller runs `host.sessions.clearDirectory(sessionId)` for each orphaned session. `collectProjectSessions` lists only the project root plus directories of worktrees that still exist in state, so a deleted worktree's sessions are no longer listed in any project list. The backend session records stay on disk (keyed to the removed directory) but are unreachable from the Agent Manager lists.
|
||||
|
||||
Consequences for the new flow:
|
||||
|
||||
- The project-scoped history cannot show a dangling "session in removed worktree" row: `projectSessionsLive()` only contains sessions found under root or live worktree directories, and the persisted managed list prunes them on removal. End-to-end: delete worktree -> button -> history -> the session is not listed, so "selecting" it is impossible. It reappears only when the worktree is re-created and `restoreWorktrees` re-attaches it.
|
||||
- Defensive guard in the picker: if a picked session claims a `worktreeId` that no longer resolves in the current project state (race between webview state push and removal), fall back to `openLocally` instead of `selectWorktree`. This mirrors the extension-side fallback in `project/messages.ts` `activateSelection` ("worktree is gone, falling back to local").
|
||||
- Open-tab nuance (not reachable through the history, no change planned): a still-open chat tab for the pruned session keeps working after removal. Message load falls back to the session record's stored directory (the deleted path); prompt-send falls through `resolveNewSessionDirectory` to the project root. So such a tab continues against the project root, not the deleted worktree.
|
||||
|
||||
### 4. Sidebar cleanup
|
||||
|
||||
- Remove `UnassignedSessionsSection` from `SidebarBody.tsx` and `ProjectSidebarBody.tsx`; delete `UnassignedSessionsSection.tsx`.
|
||||
- Remove `sessionsCollapsed` / `toggleSessions` webview plumbing: props in `SidebarBody`, `ProjectSidebarBody`, `AgentManagerApp` signals, the registry store field (`project/store.ts`), and the `agentManager.setSessionsCollapsed` post (keep the extension-side state field and message handler inert, so existing `.kilo/agent-manager.json` files and `worktree-state-manager.test.ts` stay valid).
|
||||
- WORKTREES section always grows (`am-section-grow`) and scrolls; the old grow-toggle tied to `sessionsCollapsed` (`SidebarBody.tsx` line 187) is replaced by always-grow. In single mode this removes the `max-height: 50vh` cap because `.am-section-grow .am-worktree-list` already sets `max-height: none; flex: 1`.
|
||||
- Remove unassigned session rows from keyboard navigation and jump shortcuts: `buildSidebarOrder` (drop the `sessions` param), `projectSidebarOrder` (`project-local-navigation.ts`, drop the localSessions param), `buildProjectNavEntries` (`project-nav.ts`, drop the `unassigned`/`sessionsCollapsed` mapping). Invisible rows must not receive Cmd+Alt+arrow or Cmd+1-9 jumps (this also resolves the known issue documented in `agent-manager-multi-project-sidebar-density.md`).
|
||||
- Remove now-dead helpers and props: `filterUnassignedSessions` (and its tests), `unassignedSessions` memo, `selectUnassigned`, `SidebarBody` props `unassignedSessions`/`selectUnassigned`/`sessionsCollapsed`/`toggleSessions`, `ProjectSidebarBody` prop `onSelectSession`, `SidebarBody` prop `onShowHistory` wiring stays for `ChatView`.
|
||||
- Session search palette (Cmd+F) keeps its "sessions" group; it is not the tree list and stays.
|
||||
- Agent manager `.am-list` / `.am-item` CSS used only by `UnassignedSessionsSection` can be deleted after the section is gone; other `.am-item`-style classes used by edit preview etc. stay.
|
||||
- `agent-manager-arch.test.ts` references `UnassignedSessionsSection.tsx` in the file list and in a test; remove those references.
|
||||
- Stories (`webview-ui/src/stories/agent-manager.stories.tsx`) remove the sessions fixtures in multi-project/sidebar stories and the `sessionsCollapsed: false` state fixture.
|
||||
|
||||
## Implementation tasks
|
||||
|
||||
Order matters: the section removal first, then the button, then the nav/CSS cleanup, then tests.
|
||||
|
||||
### Task 1: Remove the inline SESSIONS section
|
||||
|
||||
- `SidebarBody.tsx`: delete the `UnassignedSessionsSection` usage at the bottom; remove the import and the props `sessionsCollapsed`, `toggleSessions`, `unassignedSessions`, `selectUnassigned` from `SidebarBodyProps`; make the WORKTREES div always `am-section-grow`.
|
||||
- `ProjectSidebarBody.tsx`: delete the `UnassignedSessionsSection` usage; remove the import and the `onSelectSession` prop; remove the `localSessions` memo if unused (still used by `projectSidebarOrder`, removed in task 3).
|
||||
- Delete `UnassignedSessionsSection.tsx`.
|
||||
- `AgentManagerApp.tsx`: remove `sessionsCollapsed`, `setSessionsCollapsed`, `toggleSessions` and the `unassignedSessions` memo; remove the corresponding props passed to `SidebarBody`; remove `filterUnassignedSessions` import.
|
||||
- `project/store.ts`: remove the `sessionsCollapsed` field; `ProjectList.tsx` stops passing `onSelectSession` into the body.
|
||||
|
||||
### Task 2: Add the per-project history button
|
||||
|
||||
- `WorktreeSectionActions.tsx`: add `onHistory: () => void` prop and a `Tooltip`-wrapped history `IconButton` (`session.showHistory`).
|
||||
- `SidebarBody.tsx`: pass-through `onHistory` prop.
|
||||
- `ProjectsSection.tsx`: add `onHistory: (projectId: string) => void` prop; in the project row header actions add the history `IconButton` before the remove X (with `stopPropagation`).
|
||||
- `ProjectList.tsx`: wire `onHistory`.
|
||||
- `AgentManagerApp.tsx`:
|
||||
- Add `historyProject` signal and `openHistory(pid?: string)`: `pid` undefined -> `setHistory(true)`; `pid` set -> post `agentManager.activateSelection` `{ target: { projectId: pid, kind: "local" } }`, then `setHistoryProject(pid)`, `setHistory(true)`.
|
||||
- Pass `onHistory` to `SidebarBody` and `ProjectList`.
|
||||
- Reset `historyProject` in the close-history paths (Back, session picked, `setHistory(false)` calls for history).
|
||||
|
||||
### Task 3: Project-scoped history filter + start-location choice
|
||||
|
||||
- `src/components/history/HistoryView.tsx`: add optional `sessionIds?: Accessor<ReadonlySet<string>>` for the Local tab `SessionList`, and optional `rowActions?: (session: SessionInfo) => JSX.Element` rendered per row.
|
||||
- `src/components/history/SessionList.tsx`: forward `rowActions` into `wrapItem` (after rename/delete buttons).
|
||||
- `webview-ui/agent-manager/SessionRowActions.tsx` (new): per-row three-dot menu with `agentManager.session.openInWorktree` ("Start in a new worktree") and `agentManager.session.openLocally` ("Open in project local tabs", worktree-bound rows only).
|
||||
- `AgentManagerApp.tsx`:
|
||||
- Memo `historySessionIds` from `projectSessionsLive()[historyProject()]` filtered by `isKnownRootSession`.
|
||||
- Pass `sessionIds={historySessionIds}` and `rowActions` to `HistoryView` only when `historyProject` is set.
|
||||
- Pass `worktreeSessionIds` to `HistoryView` only when `historyProject` is unset (legacy flow unchanged).
|
||||
- Row actions: "Start in a new worktree" posts `agentManager.promoteSession` (project already active); "Open in local tabs" posts `agentManager.openSessionLocally` (with `projectId` in multi mode), then closes history.
|
||||
- New extension message `agentManager.openSessionLocally { projectId?, sessionId }`:
|
||||
- Multi project (`projectId` set): `project/messages.ts` handler moves the session to local (state `moveSession(id, null)`, `routeSession` root registration, `push()`), then `finish` with a session target so the webview's `applyProjectSelection` opens it via `focusLocalSession`.
|
||||
- Legacy (no `projectId`): provider dispatch moves the session with `moveSession(id, null)` + `registerWorktreeSession(id, root)` + `pushState()`; the webview then opens it locally.
|
||||
- `ProjectMessageDeps` gains optional `routeSession` wired in `project/wiring.ts` from `host.sessions` (`setSessionDirectory` + `registerSessionRoute`); add the type to `agent-manager/types.ts` and to the `state-gate.ts` list.
|
||||
|
||||
### Task 4: Navigation and CSS cleanup
|
||||
|
||||
- `section-helpers.ts`: remove the `sessions` parameter from `buildSidebarOrder`; update call sites and `tests/unit/section-helpers.test.ts`.
|
||||
- `project-local-navigation.ts`: remove the `localSessions` parameter from `projectSidebarOrder`; drop session nav entries.
|
||||
- `project-nav.ts`: remove `unassigned`/`sessionsCollapsed` mapping from `buildProjectNavEntries`; drop session nav entries; remove `sessionNavId` if unused.
|
||||
- `navigate.ts`: remove `filterUnassignedSessions`; remove the `selectUnassigned` glue in `AgentManagerApp` (`focusSidebarItem` else-branch, `SidebarBody` prop).
|
||||
- `agent-manager.css`: remove dead session-list rules if no longer used after task 1 (verify `am-list`, `am-item` usages first).
|
||||
- `agent-manager-arch.test.ts`: drop `UnassignedSessionsSection.tsx` references.
|
||||
- `tests/unit/navigate.test.ts`: drop the `filterUnassignedSessions` describe block.
|
||||
- `webview-ui/src/stories/agent-manager.stories.tsx`: update stories to the button-driven UI.
|
||||
|
||||
## Verification
|
||||
|
||||
From `packages/kilo-vscode`:
|
||||
|
||||
```bash
|
||||
bun run format
|
||||
bun run typecheck
|
||||
bun run lint
|
||||
bun run test:unit
|
||||
bun run check-kilocode-change
|
||||
bun run compile
|
||||
```
|
||||
|
||||
## Changeset
|
||||
|
||||
`.changeset/agent-manager-sessions-button.md`:
|
||||
|
||||
```md
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Replace the Agent Manager sidebar sessions list with a per-project history button that opens all sessions of that project, including sessions running in its worktrees.
|
||||
```
|
||||
|
||||
## Manual test
|
||||
|
||||
Open Agent Manager (`Cmd+Shift+M`). Single project:
|
||||
|
||||
1. The SESSIONS section at the bottom is gone; the WORKTREES list fills the sidebar and scrolls.
|
||||
2. Click the history icon in the WORKTREES header: the history view opens (Local/Cloud tabs); the Local list matches the current project's sessions.
|
||||
3. Type `/sessions` in the chat: behavior unchanged (history opens).
|
||||
|
||||
Multi project (`kilo-code.new.experimental.multiProject` on, add a second project):
|
||||
|
||||
4. Each project row has exactly one history icon; collapsed projects too.
|
||||
5. Click project B's button: history opens, even when project A was active; the Local list shows only B's sessions (root and worktree sessions).
|
||||
6. Pick a worktree-bound session of B: jumps to B's worktree, session selected.
|
||||
7. Pick a root session of B: opens in B's local tabs; switch back to project A: A's sessions are untouched.
|
||||
8. While project B is active and a worktree of B is selected, `Cmd`+click `/sessions` in chat: continue-in-worktree behavior preserved.
|
||||
9. Cmd+Alt+Up/Down and Cmd+1-9 no longer land on invisible session rows.
|
||||
|
||||
## Out of scope / rejected options
|
||||
|
||||
- A bespoke project-session list rendering `ProjectSessionInfo` directly (per issue #12928's out-of-scope note): rename/export/delete and session selection all require the active-project session context. Activating the project and reusing `HistoryView` is the lower-risk path.
|
||||
- Bounding the inline list to N rows with a "Show all" row (issue #12928 proposal): superseded by this design, which removes the list entirely.
|
||||
- Keeping `sessionsCollapsed` persistence on the extension side: kept inert to avoid touching `WorktreeStateManager` and its tests; can be removed in a follow-up once no webview writes to it.
|
||||
@@ -70,6 +70,10 @@ const icons: Record<string, { path: string; viewBox: string }> = {
|
||||
viewBox: "0 0 24 24",
|
||||
path: `<path d="M12 14L9 10M12 14L15 10M21 15C21 18.866 17.866 22 14 22H10C6.134 22 3 18.866 3 15V9C3 5.134 6.134 2 10 2H14C17.866 2 21 5.134 21 9V15Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>`,
|
||||
},
|
||||
local: {
|
||||
viewBox: "0 0 20 20",
|
||||
path: `<rect x="2.5" y="3.5" width="15" height="10" rx="1" stroke="currentColor"/><path d="M6 16.5H14" stroke="currentColor" stroke-linecap="square"/><path d="M10 13.5V16.5" stroke="currentColor"/>`,
|
||||
},
|
||||
}
|
||||
|
||||
type Name = keyof typeof icons
|
||||
|
||||
@@ -74,7 +74,7 @@ import type { ProjectContext } from "./project/context"
|
||||
import { ProjectContexts } from "./project/contexts"
|
||||
import { hydrateExpanded } from "./project/hydrate"
|
||||
import { createMultiVersion, type MultiVersionHost } from "./provider-multi-version"
|
||||
import { handleProjectMessage, type ProjectMessageDeps } from "./project/messages"
|
||||
import { handleProjectMessage, routeProjectSession, type ProjectMessageDeps } from "./project/messages"
|
||||
import { createProjectWiring, type ProjectWiring } from "./project/wiring"
|
||||
import { ProjectScope } from "./project/scope"
|
||||
import type { AgentManagerOutMessage, AgentManagerInMessage } from "./types"
|
||||
@@ -197,6 +197,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
pushState: (ctx) => this.pushState(ctx),
|
||||
changed: () => this.onWorkspaceChanged(),
|
||||
selected: (target) => this.postToWebview({ type: "agentManager.selectionActivated", target }),
|
||||
routeSession: (pid, sid, dir, gen) => routeProjectSession(this.panel?.sessions, pid, sid, dir, gen),
|
||||
})
|
||||
this.registry = wiring.registry
|
||||
this.contexts = wiring.contexts
|
||||
@@ -323,9 +324,8 @@ export class AgentManagerProvider implements Disposable {
|
||||
}
|
||||
const info = ev.properties?.info
|
||||
const dir = info?.directory
|
||||
// Session events from sync or older backends can lack time/directory; a
|
||||
// throw here would escape into the SSE dispatch loop and starve the other
|
||||
// listeners (there is no per-listener error isolation).
|
||||
// Session events from sync or older backends can lack time/directory; a throw
|
||||
// would escape into the SSE dispatch loop and starve the other listeners.
|
||||
if (!info?.time || !dir || (info.parentID !== undefined && info.parentID !== null)) return
|
||||
const ctx = this.contexts.byDirectory(dir)
|
||||
if (!ctx || ctx.lifecycle !== "ready") return
|
||||
|
||||
@@ -12,7 +12,25 @@ import type { ProjectRegistry } from "./registry"
|
||||
import type { ProjectContext, ProjectInitResult } from "./context"
|
||||
import type { ProjectContexts } from "./contexts"
|
||||
import { projectIdFor, resolveProjectRoot, samePath } from "./paths"
|
||||
import type { SidebarTarget } from "./route"
|
||||
import type { SidebarTarget, SessionRef } from "./route"
|
||||
|
||||
/** Route one session to a directory inside a project via the shared session provider. */
|
||||
export function routeProjectSession(
|
||||
sessions:
|
||||
| {
|
||||
setSessionDirectory(id: string, directory: string): void
|
||||
registerSessionRoute?(ref: SessionRef, directory: string, generation: number): void
|
||||
}
|
||||
| undefined,
|
||||
projectId: string,
|
||||
sessionId: string,
|
||||
directory: string,
|
||||
generation: number,
|
||||
): void {
|
||||
if (!sessions) return
|
||||
sessions.setSessionDirectory(sessionId, directory)
|
||||
sessions.registerSessionRoute?.({ projectId, sessionId }, directory, generation)
|
||||
}
|
||||
|
||||
export interface ProjectMessageDeps {
|
||||
registry: ProjectRegistry
|
||||
@@ -35,6 +53,8 @@ export interface ProjectMessageDeps {
|
||||
openSettings: (tab?: string, projectId?: string) => void
|
||||
/** Ensure a context's repository state is ready (no-op once initialized). */
|
||||
ready: (ctx: ProjectContext) => Promise<ProjectInitResult>
|
||||
/** Route one session to a directory inside a project (session override + project route). */
|
||||
routeSession?: (projectId: string, sessionId: string, directory: string, generation: number) => void
|
||||
log: (...args: unknown[]) => void
|
||||
}
|
||||
|
||||
@@ -64,6 +84,11 @@ export async function handleProjectMessage(m: AgentManagerInMessage, deps: Proje
|
||||
await activateSelection(m.target, deps, m.restore === true)
|
||||
return true
|
||||
}
|
||||
if (m.type === "agentManager.openSessionLocally") {
|
||||
if (!m.projectId) return false
|
||||
await openSessionLocally(m.projectId, m.sessionId, deps)
|
||||
return true
|
||||
}
|
||||
if (m.type === "agentManager.rememberTarget") {
|
||||
rememberTarget(m.projectId, m.target, deps)
|
||||
return true
|
||||
@@ -106,6 +131,35 @@ async function activateSelection(requested: SidebarTarget, deps: ProjectMessageD
|
||||
finish(target, deps)
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a worktree-bound session back to the project root and open it in the
|
||||
* project's local tabs. Fall back to local gracefully when the worktree is
|
||||
* already gone (the session may be live only).
|
||||
*/
|
||||
async function openSessionLocally(projectId: string, sessionId: string, deps: ProjectMessageDeps): Promise<void> {
|
||||
if (disabled(deps)) return
|
||||
const ctx = deps.contexts.resolve(projectId)
|
||||
if (!ctx || !deps.contexts.usable(projectId)) {
|
||||
deps.error("The project is unavailable. Check that the repository still exists.")
|
||||
return
|
||||
}
|
||||
const result = await deps.ready(ctx)
|
||||
if (!result.current || !result.ok) {
|
||||
deps.error("The project is not ready yet. Expand it before selecting a worktree or session.")
|
||||
deps.push()
|
||||
return
|
||||
}
|
||||
const state = ctx.peekState()
|
||||
if (!state?.getSession(sessionId) && !ctx.hasLiveSession(sessionId)) {
|
||||
deps.log(`openSessionLocally: unknown session ${sessionId}`)
|
||||
return
|
||||
}
|
||||
state?.moveSession(sessionId, null)
|
||||
deps.routeSession?.(projectId, sessionId, ctx.root, ctx.generation)
|
||||
deps.push()
|
||||
finish({ projectId, kind: "session", sessionId }, deps)
|
||||
}
|
||||
|
||||
/** Commit the active project, persist the target, and acknowledge the selection. */
|
||||
function finish(target: SidebarTarget, deps: ProjectMessageDeps): void {
|
||||
const previous = deps.contexts.active()?.id
|
||||
|
||||
@@ -9,6 +9,7 @@ export const STATE_GATED = new Set<string>([
|
||||
"agentManager.deleteWorktree",
|
||||
"agentManager.removeStaleWorktree",
|
||||
"agentManager.openLocally",
|
||||
"agentManager.openSessionLocally",
|
||||
"agentManager.addSessionToWorktree",
|
||||
"agentManager.closeSession",
|
||||
"agentManager.persistSession",
|
||||
|
||||
@@ -44,6 +44,8 @@ export function createProjectWiring(opts: {
|
||||
changed: () => void
|
||||
/** Acknowledge an atomically validated sidebar selection. */
|
||||
selected: (target: import("./route").SidebarTarget) => void
|
||||
/** Route one session to a directory inside a project (override + project route). */
|
||||
routeSession?: (projectId: string, sessionId: string, directory: string, generation: number) => void
|
||||
}): ProjectWiring {
|
||||
const registry = new ProjectRegistry(
|
||||
{ read: () => opts.host.readProjects(), write: (value) => opts.host.writeProjects(value) },
|
||||
@@ -66,6 +68,7 @@ export function createProjectWiring(opts: {
|
||||
ready: opts.ready,
|
||||
push: opts.push,
|
||||
selected: opts.selected,
|
||||
routeSession: opts.routeSession,
|
||||
error: (message) => opts.host.showError(message),
|
||||
openSettings: (tab, projectId) => opts.host.openSettings(tab, projectId),
|
||||
log: opts.log,
|
||||
|
||||
@@ -575,6 +575,13 @@ interface AddSessionToWorktreeIn {
|
||||
sessionId?: string
|
||||
}
|
||||
|
||||
/** Move a session back to the project root and open it in the local tabs. */
|
||||
interface OpenSessionLocallyIn {
|
||||
type: "agentManager.openSessionLocally"
|
||||
projectId?: string
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
interface CloseSessionIn {
|
||||
type: "agentManager.closeSession"
|
||||
sessionId: string
|
||||
@@ -1081,6 +1088,7 @@ export type AgentManagerInMessage =
|
||||
| RemoveStaleWorktreeIn
|
||||
| PromoteSessionIn
|
||||
| OpenLocallyIn
|
||||
| OpenSessionLocallyIn
|
||||
| AddSessionToWorktreeIn
|
||||
| CloseSessionIn
|
||||
| PersistSessionIn
|
||||
|
||||
@@ -24,7 +24,7 @@ const TSX_FILES = [
|
||||
path.join(ROOT, "webview-ui/agent-manager/AgentManagerApp.tsx"),
|
||||
path.join(ROOT, "webview-ui/agent-manager/SubagentPanel.tsx"),
|
||||
path.join(ROOT, "webview-ui/agent-manager/EditPreviewPanel.tsx"),
|
||||
path.join(ROOT, "webview-ui/agent-manager/UnassignedSessionsSection.tsx"),
|
||||
path.join(ROOT, "webview-ui/agent-manager/SessionRowActions.tsx"),
|
||||
path.join(ROOT, "webview-ui/agent-manager/NewWorktreeDialog.tsx"),
|
||||
path.join(ROOT, "webview-ui/agent-manager/ProjectSelect.tsx"),
|
||||
path.join(ROOT, "webview-ui/agent-manager/sortable-tab.tsx"),
|
||||
@@ -456,9 +456,9 @@ describe("Agent Manager Worktree Actions", () => {
|
||||
})
|
||||
|
||||
it("does not attribute the new-worktree shortcut to session promotion", () => {
|
||||
const source = fs.readFileSync(path.join(ROOT, "webview-ui/agent-manager/UnassignedSessionsSection.tsx"), "utf-8")
|
||||
const source = fs.readFileSync(path.join(ROOT, "webview-ui/agent-manager/SessionRowActions.tsx"), "utf-8")
|
||||
|
||||
expect(source).toContain('<Tooltip value={t("agentManager.session.openInWorktree")}')
|
||||
expect(source).toContain('t("agentManager.session.openInWorktree")')
|
||||
expect(source).not.toContain("TooltipKeybind")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,13 +5,11 @@ import {
|
||||
validateLocalSession,
|
||||
adjacentHint,
|
||||
canOpenRootSession,
|
||||
filterUnassignedSessions,
|
||||
remoteSessions,
|
||||
buildProjectNavOrder,
|
||||
resolveProjectNav,
|
||||
localNavId,
|
||||
worktreeNavId,
|
||||
sessionNavId,
|
||||
type ProjectNavInput,
|
||||
LOCAL,
|
||||
} from "../../webview-ui/agent-manager/navigate"
|
||||
@@ -203,105 +201,6 @@ describe("adjacentHint", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("filterUnassignedSessions", () => {
|
||||
const at = (day: number) => `2026-01-${String(day).padStart(2, "0")}T00:00:00.000Z`
|
||||
const info = (id: string, day: number, parentID: string | null = null) => ({
|
||||
id,
|
||||
createdAt: at(day),
|
||||
parentID,
|
||||
})
|
||||
|
||||
it("filters sparse session updates until ancestry is known", () => {
|
||||
const result = filterUnassignedSessions([{ id: "unknown", createdAt: at(1) }], new Set(), new Set())
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it("keeps root sessions with null parent IDs", () => {
|
||||
const result = filterUnassignedSessions([info("root", 1, null)], new Set(), new Set())
|
||||
|
||||
expect(result.map((s) => s.id)).toEqual(["root"])
|
||||
})
|
||||
|
||||
it("filters child sessions with parent IDs", () => {
|
||||
const result = filterUnassignedSessions(
|
||||
[info("parent", 2), info("child", 3, "parent"), info("orphan", 4, "missing")],
|
||||
new Set(),
|
||||
new Set(),
|
||||
)
|
||||
|
||||
expect(result.map((s) => s.id)).toEqual(["parent"])
|
||||
})
|
||||
|
||||
it("filters string parent IDs even when they are empty", () => {
|
||||
const result = filterUnassignedSessions([info("blank", 2, ""), info("root", 1)], new Set(), new Set())
|
||||
|
||||
expect(result.map((s) => s.id)).toEqual(["root"])
|
||||
})
|
||||
|
||||
it("filters worktree sessions while keeping other roots", () => {
|
||||
const result = filterUnassignedSessions(
|
||||
[info("root", 1), info("worktree", 3), info("other", 2)],
|
||||
new Set(["worktree"]),
|
||||
new Set(),
|
||||
)
|
||||
|
||||
expect(result.map((s) => s.id)).toEqual(["other", "root"])
|
||||
})
|
||||
|
||||
it("filters local tab sessions while keeping other roots", () => {
|
||||
const result = filterUnassignedSessions(
|
||||
[info("root", 1), info("local", 3), info("other", 2)],
|
||||
new Set(),
|
||||
new Set(["local"]),
|
||||
)
|
||||
|
||||
expect(result.map((s) => s.id)).toEqual(["other", "root"])
|
||||
})
|
||||
|
||||
it("applies child, worktree, and local filters before sorting", () => {
|
||||
const result = filterUnassignedSessions(
|
||||
[info("old-root", 1), info("child", 6, "old-root"), info("worktree", 5), info("local", 4), info("new-root", 3)],
|
||||
new Set(["worktree"]),
|
||||
new Set(["local"]),
|
||||
)
|
||||
|
||||
expect(result.map((s) => s.id)).toEqual(["new-root", "old-root"])
|
||||
})
|
||||
|
||||
it("returns an empty list when every session is filtered", () => {
|
||||
const result = filterUnassignedSessions(
|
||||
[info("child", 3, "root"), info("worktree", 2), info("local", 1)],
|
||||
new Set(["worktree"]),
|
||||
new Set(["local"]),
|
||||
)
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it("does not mutate the input order", () => {
|
||||
const sessions = [info("old", 1), info("new", 3), info("mid", 2)]
|
||||
|
||||
filterUnassignedSessions(sessions, new Set(), new Set())
|
||||
|
||||
expect(sessions.map((s) => s.id)).toEqual(["old", "new", "mid"])
|
||||
})
|
||||
|
||||
it("preserves session objects and extra fields", () => {
|
||||
const root = { ...info("root", 1), title: "Existing session" }
|
||||
const result = filterUnassignedSessions([root], new Set(), new Set())
|
||||
|
||||
expect(result[0]).toBe(root)
|
||||
expect(result[0]?.title).toBe("Existing session")
|
||||
})
|
||||
|
||||
it("keeps a parent root when its child is filtered", () => {
|
||||
const result = filterUnassignedSessions([info("root", 1), info("child", 2, "root")], new Set(), new Set())
|
||||
|
||||
expect(result.map((s) => s.id)).toEqual(["root"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("canOpenRootSession", () => {
|
||||
const sessions = [{ id: "root", parentID: null }, { id: "child", parentID: "root" }, { id: "sparse" }]
|
||||
|
||||
@@ -338,17 +237,10 @@ describe("remoteSessions", () => {
|
||||
})
|
||||
|
||||
describe("buildProjectNavOrder", () => {
|
||||
const project = (
|
||||
p: Omit<ProjectNavInput, "sessionsCollapsed"> & { sessionsCollapsed?: boolean },
|
||||
): ProjectNavInput => ({
|
||||
...p,
|
||||
sessionsCollapsed: p.sessionsCollapsed ?? false,
|
||||
})
|
||||
|
||||
it("builds A Local -> A worktree -> B Local -> B worktree -> B session across expanded projects", () => {
|
||||
it("builds A Local -> A worktree -> B Local -> B worktree across expanded projects", () => {
|
||||
const order = buildProjectNavOrder([
|
||||
project({ id: "A", expanded: true, worktrees: [{ id: "aw1" }], sections: [], unassigned: [] }),
|
||||
project({ id: "B", expanded: true, worktrees: [{ id: "bw1" }], sections: [], unassigned: [{ id: "bs1" }] }),
|
||||
{ id: "A", expanded: true, worktrees: [{ id: "aw1" }], sections: [] },
|
||||
{ id: "B", expanded: true, worktrees: [{ id: "bw1" }], sections: [] },
|
||||
])
|
||||
|
||||
expect(order.map((e) => e.id)).toEqual([
|
||||
@@ -356,33 +248,27 @@ describe("buildProjectNavOrder", () => {
|
||||
worktreeNavId("A", "aw1"),
|
||||
localNavId("B"),
|
||||
worktreeNavId("B", "bw1"),
|
||||
sessionNavId("B", "bs1"),
|
||||
])
|
||||
expect(order.map((e) => e.target)).toEqual([
|
||||
{ projectId: "A", kind: "local" },
|
||||
{ projectId: "A", kind: "worktree", worktreeId: "aw1" },
|
||||
{ projectId: "B", kind: "local" },
|
||||
{ projectId: "B", kind: "worktree", worktreeId: "bw1" },
|
||||
{ projectId: "B", kind: "session", sessionId: "bs1" },
|
||||
])
|
||||
})
|
||||
|
||||
it("uses project-qualified composite ids, never raw worktree/session ids", () => {
|
||||
const order = buildProjectNavOrder([
|
||||
project({ id: "A", expanded: true, worktrees: [{ id: "aw1" }], sections: [], unassigned: [{ id: "as1" }] }),
|
||||
])
|
||||
it("uses project-qualified composite ids, never raw worktree ids", () => {
|
||||
const order = buildProjectNavOrder([{ id: "A", expanded: true, worktrees: [{ id: "aw1" }], sections: [] }])
|
||||
const ids = order.map((e) => e.id)
|
||||
expect(ids).not.toContain("aw1")
|
||||
expect(ids).not.toContain("as1")
|
||||
expect(ids).toContain("A:local")
|
||||
expect(ids).toContain("A:wt:aw1")
|
||||
expect(ids).toContain("A:sess:as1")
|
||||
})
|
||||
|
||||
it("excludes collapsed projects entirely", () => {
|
||||
const order = buildProjectNavOrder([
|
||||
project({ id: "A", expanded: true, worktrees: [{ id: "aw1" }], sections: [], unassigned: [] }),
|
||||
project({ id: "C", expanded: false, worktrees: [{ id: "cw1" }], sections: [], unassigned: [{ id: "cs1" }] }),
|
||||
{ id: "A", expanded: true, worktrees: [{ id: "aw1" }], sections: [] },
|
||||
{ id: "C", expanded: false, worktrees: [{ id: "cw1" }], sections: [] },
|
||||
])
|
||||
expect(order.map((e) => e.id)).toEqual([localNavId("A"), worktreeNavId("A", "aw1")])
|
||||
expect(order.some((e) => e.target.kind === "worktree" && e.target.worktreeId === "cw1")).toBe(false)
|
||||
@@ -390,33 +276,31 @@ describe("buildProjectNavOrder", () => {
|
||||
|
||||
it("excludes worktrees inside collapsed sections but keeps ungrouped ones", () => {
|
||||
const order = buildProjectNavOrder([
|
||||
project({
|
||||
{
|
||||
id: "A",
|
||||
expanded: true,
|
||||
worktrees: [{ id: "aw1", sectionId: "s1" }, { id: "aw2" }],
|
||||
sections: [{ id: "s1", collapsed: true }],
|
||||
unassigned: [],
|
||||
}),
|
||||
},
|
||||
])
|
||||
expect(order.map((e) => e.id)).toEqual([localNavId("A"), worktreeNavId("A", "aw2")])
|
||||
})
|
||||
|
||||
it("renders ungrouped worktrees before section members (matching the project body)", () => {
|
||||
const order = buildProjectNavOrder([
|
||||
project({
|
||||
{
|
||||
id: "A",
|
||||
expanded: true,
|
||||
worktrees: [{ id: "aw1", sectionId: "s1" }, { id: "aw2" }],
|
||||
sections: [{ id: "s1", collapsed: false }],
|
||||
unassigned: [],
|
||||
}),
|
||||
},
|
||||
])
|
||||
expect(order.map((e) => e.id)).toEqual([localNavId("A"), worktreeNavId("A", "aw2"), worktreeNavId("A", "aw1")])
|
||||
})
|
||||
|
||||
it("follows persisted top-level section order and worktree order", () => {
|
||||
const order = buildProjectNavOrder([
|
||||
project({
|
||||
{
|
||||
id: "A",
|
||||
expanded: true,
|
||||
worktrees: [{ id: "aw1", sectionId: "s1" }, { id: "aw2" }, { id: "aw3", sectionId: "s2" }],
|
||||
@@ -425,8 +309,7 @@ describe("buildProjectNavOrder", () => {
|
||||
{ id: "s1", collapsed: false },
|
||||
{ id: "s2", collapsed: false },
|
||||
],
|
||||
unassigned: [],
|
||||
}),
|
||||
},
|
||||
])
|
||||
|
||||
expect(order.map((e) => e.id)).toEqual([
|
||||
@@ -439,14 +322,13 @@ describe("buildProjectNavOrder", () => {
|
||||
|
||||
it("keeps multi-version worktrees adjacent", () => {
|
||||
const order = buildProjectNavOrder([
|
||||
project({
|
||||
{
|
||||
id: "A",
|
||||
expanded: true,
|
||||
worktrees: [{ id: "aw1", groupId: "g" }, { id: "aw2" }, { id: "aw3", groupId: "g" }],
|
||||
worktreeOrder: ["aw1", "aw2", "aw3"],
|
||||
sections: [],
|
||||
unassigned: [],
|
||||
}),
|
||||
},
|
||||
])
|
||||
|
||||
expect(order.map((e) => e.id)).toEqual([
|
||||
@@ -459,7 +341,7 @@ describe("buildProjectNavOrder", () => {
|
||||
|
||||
it("matches raw ungrouped order when sections are present", () => {
|
||||
const order = buildProjectNavOrder([
|
||||
project({
|
||||
{
|
||||
id: "A",
|
||||
expanded: true,
|
||||
worktrees: [
|
||||
@@ -470,8 +352,7 @@ describe("buildProjectNavOrder", () => {
|
||||
],
|
||||
worktreeOrder: ["aw1", "aw2", "aw3", "s1", "aw4"],
|
||||
sections: [{ id: "s1", collapsed: false }],
|
||||
unassigned: [],
|
||||
}),
|
||||
},
|
||||
])
|
||||
|
||||
expect(order.map((e) => e.id)).toEqual([
|
||||
@@ -483,49 +364,26 @@ describe("buildProjectNavOrder", () => {
|
||||
])
|
||||
})
|
||||
|
||||
it("excludes unassigned sessions when the sessions section is collapsed", () => {
|
||||
const order = buildProjectNavOrder([
|
||||
project({
|
||||
id: "A",
|
||||
expanded: true,
|
||||
worktrees: [{ id: "aw1" }],
|
||||
sections: [],
|
||||
sessionsCollapsed: true,
|
||||
unassigned: [{ id: "as1" }, { id: "as2" }],
|
||||
}),
|
||||
])
|
||||
expect(order.map((e) => e.id)).toEqual([localNavId("A"), worktreeNavId("A", "aw1")])
|
||||
})
|
||||
|
||||
it("returns an empty order when every project is collapsed", () => {
|
||||
const order = buildProjectNavOrder([
|
||||
project({ id: "A", expanded: false, worktrees: [{ id: "aw1" }], sections: [], unassigned: [] }),
|
||||
])
|
||||
const order = buildProjectNavOrder([{ id: "A", expanded: false, worktrees: [{ id: "aw1" }], sections: [] }])
|
||||
expect(order).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolveProjectNav", () => {
|
||||
// A Local -> A worktree -> B Local -> B worktree -> B session
|
||||
// A Local -> A worktree -> B Local -> B worktree
|
||||
const inputs: ProjectNavInput[] = [
|
||||
{ id: "A", expanded: true, worktrees: [{ id: "aw1" }], sections: [], sessionsCollapsed: false, unassigned: [] },
|
||||
{
|
||||
id: "B",
|
||||
expanded: true,
|
||||
worktrees: [{ id: "bw1" }],
|
||||
sections: [],
|
||||
sessionsCollapsed: false,
|
||||
unassigned: [{ id: "bs1" }],
|
||||
},
|
||||
{ id: "A", expanded: true, worktrees: [{ id: "aw1" }], sections: [] },
|
||||
{ id: "B", expanded: true, worktrees: [{ id: "bw1" }], sections: [] },
|
||||
]
|
||||
const order = buildProjectNavOrder(inputs)
|
||||
// Collapsed project C must not appear in the order
|
||||
const withCollapsed = buildProjectNavOrder([
|
||||
...inputs,
|
||||
{ id: "C", expanded: false, worktrees: [{ id: "cw1" }], sections: [], sessionsCollapsed: false, unassigned: [] },
|
||||
{ id: "C", expanded: false, worktrees: [{ id: "cw1" }], sections: [] },
|
||||
])
|
||||
|
||||
it("walks forward A Local -> A worktree -> B Local -> B worktree -> B session", () => {
|
||||
it("walks forward A Local -> A worktree -> B Local -> B worktree", () => {
|
||||
let current: string | undefined = undefined
|
||||
const trail: string[] = []
|
||||
for (let i = 0; i < 6; i++) {
|
||||
@@ -534,17 +392,11 @@ describe("resolveProjectNav", () => {
|
||||
current = entry.id
|
||||
trail.push(entry.id)
|
||||
}
|
||||
expect(trail).toEqual([
|
||||
localNavId("A"),
|
||||
worktreeNavId("A", "aw1"),
|
||||
localNavId("B"),
|
||||
worktreeNavId("B", "bw1"),
|
||||
sessionNavId("B", "bs1"),
|
||||
])
|
||||
expect(trail).toEqual([localNavId("A"), worktreeNavId("A", "aw1"), localNavId("B"), worktreeNavId("B", "bw1")])
|
||||
})
|
||||
|
||||
it("walks in reverse B session -> B worktree -> B Local -> A worktree -> A Local", () => {
|
||||
let current: string | undefined = sessionNavId("B", "bs1")
|
||||
it("walks in reverse B worktree -> B Local -> A worktree -> A Local", () => {
|
||||
let current: string | undefined = worktreeNavId("B", "bw1")
|
||||
const trail: string[] = [current]
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const entry = resolveProjectNav("up", current, order)
|
||||
@@ -552,13 +404,7 @@ describe("resolveProjectNav", () => {
|
||||
current = entry.id
|
||||
trail.push(current)
|
||||
}
|
||||
expect(trail).toEqual([
|
||||
sessionNavId("B", "bs1"),
|
||||
worktreeNavId("B", "bw1"),
|
||||
localNavId("B"),
|
||||
worktreeNavId("A", "aw1"),
|
||||
localNavId("A"),
|
||||
])
|
||||
expect(trail).toEqual([worktreeNavId("B", "bw1"), localNavId("B"), worktreeNavId("A", "aw1"), localNavId("A")])
|
||||
})
|
||||
|
||||
it("returns undefined at the top boundary (up from first)", () => {
|
||||
@@ -566,12 +412,12 @@ describe("resolveProjectNav", () => {
|
||||
})
|
||||
|
||||
it("returns undefined at the bottom boundary (down from last)", () => {
|
||||
expect(resolveProjectNav("down", sessionNavId("B", "bs1"), order)).toBeUndefined()
|
||||
expect(resolveProjectNav("down", worktreeNavId("B", "bw1"), order)).toBeUndefined()
|
||||
})
|
||||
|
||||
it("does not wrap around", () => {
|
||||
expect(resolveProjectNav("up", localNavId("A"), order)).toBeUndefined()
|
||||
expect(resolveProjectNav("down", sessionNavId("B", "bs1"), order)).toBeUndefined()
|
||||
expect(resolveProjectNav("down", worktreeNavId("B", "bw1"), order)).toBeUndefined()
|
||||
})
|
||||
|
||||
it("treats an unknown current as before-first (down -> first, up -> undefined)", () => {
|
||||
@@ -592,7 +438,7 @@ describe("resolveProjectNav", () => {
|
||||
it("collapsed project C is excluded from the order", () => {
|
||||
expect(withCollapsed.length).toBe(order.length)
|
||||
expect(withCollapsed.some((e) => e.id === worktreeNavId("C", "cw1"))).toBe(false)
|
||||
// Forward walk still ends at B session, never reaching C
|
||||
// Forward walk still ends at B worktree, never reaching C
|
||||
let current: string | undefined = undefined
|
||||
let last: string | undefined
|
||||
for (let i = 0; i < 10; i++) {
|
||||
@@ -601,7 +447,7 @@ describe("resolveProjectNav", () => {
|
||||
current = entry.id
|
||||
last = entry.id
|
||||
}
|
||||
expect(last).toBe(sessionNavId("B", "bs1"))
|
||||
expect(last).toBe(worktreeNavId("B", "bw1"))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -636,25 +482,15 @@ describe("createProjectNav", () => {
|
||||
worktrees: { id: string; sectionId?: string }[],
|
||||
sessions: { id: string; worktreeId: string | null }[],
|
||||
sections: { id: string; collapsed: boolean }[] = [],
|
||||
sessionsCollapsed = false,
|
||||
): AgentManagerStateMessage =>
|
||||
({
|
||||
type: "agentManager.state",
|
||||
worktrees: worktrees as never,
|
||||
sessions: sessions as never,
|
||||
sections,
|
||||
sessionsCollapsed,
|
||||
}) as AgentManagerStateMessage
|
||||
|
||||
const session = (id: string): ProjectSessionInfo => ({
|
||||
id,
|
||||
title: id,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
worktreeId: null,
|
||||
})
|
||||
|
||||
// A (expanded): worktree aw1. B (expanded): worktree bw1 + unassigned bs1.
|
||||
// A (expanded): worktree aw1. B (expanded): worktree bw1.
|
||||
// C (collapsed): worktree cw1 — must never be reached.
|
||||
const projects = () => [project("A", true), project("B", true), project("C", false)]
|
||||
const states = () => ({
|
||||
@@ -662,7 +498,6 @@ describe("createProjectNav", () => {
|
||||
B: state([{ id: "bw1" }], [{ id: "bs1", worktreeId: null }]),
|
||||
C: state([{ id: "cw1" }], []),
|
||||
})
|
||||
const sessions = () => ({ A: [], B: [session("bs1")], C: [] })
|
||||
|
||||
const run = (
|
||||
fn: (nav: ReturnType<typeof createProjectNav>) => void,
|
||||
@@ -683,7 +518,6 @@ describe("createProjectNav", () => {
|
||||
focus,
|
||||
projects,
|
||||
states,
|
||||
sessions,
|
||||
activeProjectId: () => activeProjectId,
|
||||
selection: () => selection,
|
||||
currentSessionID: () => currentSessionID,
|
||||
@@ -724,16 +558,19 @@ describe("createProjectNav", () => {
|
||||
return posted
|
||||
}
|
||||
|
||||
it("multi-project step traverses A Local -> A worktree -> B Local -> B worktree -> B session", () => {
|
||||
it("multi-project step traverses A Local -> A worktree -> B Local -> B worktree", () => {
|
||||
expect(stepOnce("down", LOCAL, "A", undefined)).toEqual({ projectId: "A", kind: "worktree", worktreeId: "aw1" })
|
||||
expect(stepOnce("down", "aw1", "A", undefined)).toEqual({ projectId: "B", kind: "local" })
|
||||
expect(stepOnce("down", LOCAL, "B", undefined)).toEqual({ projectId: "B", kind: "worktree", worktreeId: "bw1" })
|
||||
expect(stepOnce("down", "bw1", "B", undefined)).toEqual({ projectId: "B", kind: "session", sessionId: "bs1" })
|
||||
expect(stepOnce("down", null, "B", "bs1")).toBeUndefined()
|
||||
expect(stepOnce("down", "bw1", "B", undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it("multi-project step reverses B session -> B worktree -> B Local -> A worktree -> A Local", () => {
|
||||
expect(stepOnce("up", null, "B", "bs1")).toEqual({ projectId: "B", kind: "worktree", worktreeId: "bw1" })
|
||||
it("multi-project step treats a local session tab as B Local", () => {
|
||||
expect(stepOnce("down", null, "B", "bs1")).toEqual({ projectId: "B", kind: "worktree", worktreeId: "bw1" })
|
||||
expect(stepOnce("up", null, "B", "bs1")).toEqual({ projectId: "A", kind: "worktree", worktreeId: "aw1" })
|
||||
})
|
||||
|
||||
it("multi-project step reverses B worktree -> B Local -> A worktree -> A Local", () => {
|
||||
expect(stepOnce("up", "bw1", "B", undefined)).toEqual({ projectId: "B", kind: "local" })
|
||||
expect(stepOnce("up", LOCAL, "B", undefined)).toEqual({ projectId: "A", kind: "worktree", worktreeId: "aw1" })
|
||||
expect(stepOnce("up", "aw1", "A", undefined)).toEqual({ projectId: "A", kind: "local" })
|
||||
@@ -741,14 +578,13 @@ describe("createProjectNav", () => {
|
||||
})
|
||||
|
||||
it("collapsed project C is never reached via jump", () => {
|
||||
// Global order: A:local(0), A:aw1(1), B:local(2), B:bw1(3), B:bs1(4).
|
||||
// Global order: A:local(0), A:aw1(1), B:local(2), B:bw1(3).
|
||||
expect(jumpOnce(0)).toEqual({ projectId: "A", kind: "local" })
|
||||
expect(jumpOnce(1)).toEqual({ projectId: "A", kind: "worktree", worktreeId: "aw1" })
|
||||
expect(jumpOnce(2)).toEqual({ projectId: "B", kind: "local" })
|
||||
expect(jumpOnce(3)).toEqual({ projectId: "B", kind: "worktree", worktreeId: "bw1" })
|
||||
expect(jumpOnce(4)).toEqual({ projectId: "B", kind: "session", sessionId: "bs1" })
|
||||
// Past the end — and C's collapsed worktree is never reachable by any index.
|
||||
expect(jumpOnce(5)).toBeUndefined()
|
||||
expect(jumpOnce(4)).toBeUndefined()
|
||||
expect(jumpOnce(99)).toBeUndefined()
|
||||
})
|
||||
|
||||
|
||||
@@ -174,7 +174,7 @@ describe("buildSidebarOrder", () => {
|
||||
it("returns LOCAL + all sorted worktrees when no sections exist", () => {
|
||||
const sorted = [wt("a"), wt("b"), wt("c")]
|
||||
const items = buildTopLevelItems([], [], sorted, [])
|
||||
const result = buildSidebarOrder(items, sorted, [], () => [], [])
|
||||
const result = buildSidebarOrder(items, sorted, [], () => [])
|
||||
expect(result).toEqual([
|
||||
{ type: "local", id: "local" },
|
||||
{ type: "wt", id: "a" },
|
||||
@@ -191,7 +191,7 @@ describe("buildSidebarOrder", () => {
|
||||
const sorted = [w1, w2, w3]
|
||||
const items = buildTopLevelItems([s1], [w3], sorted, ["s1", "w3"])
|
||||
const members = (id: string) => (id === "s1" ? [w1, w2] : [])
|
||||
const result = buildSidebarOrder(items, sorted, [s1], members, [])
|
||||
const result = buildSidebarOrder(items, sorted, [s1], members)
|
||||
expect(result).toEqual([
|
||||
{ type: "local", id: "local" },
|
||||
{ type: "wt", id: "w3" },
|
||||
@@ -207,7 +207,7 @@ describe("buildSidebarOrder", () => {
|
||||
const sorted = [w1, w2]
|
||||
const items = buildTopLevelItems([s1], [w2], sorted, ["s1", "w2"])
|
||||
const members = (id: string) => (id === "s1" ? [w1] : [])
|
||||
const result = buildSidebarOrder(items, sorted, [s1], members, [])
|
||||
const result = buildSidebarOrder(items, sorted, [s1], members)
|
||||
expect(result).toEqual([
|
||||
{ type: "local", id: "local" },
|
||||
{ type: "wt", id: "w2" },
|
||||
@@ -227,20 +227,17 @@ describe("buildSidebarOrder", () => {
|
||||
if (id === "s2") return [w3]
|
||||
return []
|
||||
}
|
||||
const result = buildSidebarOrder(items, sorted, [s1, s2], members, [])
|
||||
const result = buildSidebarOrder(items, sorted, [s1, s2], members)
|
||||
expect(result.map((r) => r.id)).toEqual(["local", "w2", "w1", "w3"])
|
||||
})
|
||||
|
||||
it("appends unassigned sessions after worktrees", () => {
|
||||
it("appends nothing after worktrees (sessions live in the history view)", () => {
|
||||
const sorted = [wt("a")]
|
||||
const items = buildTopLevelItems([], [], sorted, [])
|
||||
const sessions = [{ id: "sess1" }, { id: "sess2" }]
|
||||
const result = buildSidebarOrder(items, sorted, [], () => [], sessions)
|
||||
const result = buildSidebarOrder(items, sorted, [], () => [])
|
||||
expect(result).toEqual([
|
||||
{ type: "local", id: "local" },
|
||||
{ type: "wt", id: "a" },
|
||||
{ type: "session", id: "sess1" },
|
||||
{ type: "session", id: "sess2" },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -47,6 +47,7 @@ import type {
|
||||
TerminalDestination,
|
||||
TerminalFont,
|
||||
} from "../src/types/messages"
|
||||
import { historyRowActions as historyRowActionsFactory } from "./history-actions"
|
||||
import { readFontSize } from "../src/font-size"
|
||||
import { IndexingProvider } from "../src/context/indexing"
|
||||
import {} from "@thisbeyond/solid-dnd"
|
||||
@@ -120,7 +121,6 @@ import {
|
||||
isKnownRootSession,
|
||||
nextSelectionAfterDelete,
|
||||
adjacentHint,
|
||||
filterUnassignedSessions,
|
||||
focusChatSearch,
|
||||
LOCAL,
|
||||
} from "./navigate"
|
||||
@@ -186,8 +186,6 @@ import {
|
||||
sortWorktrees,
|
||||
type TopLevelItem,
|
||||
} from "./section-helpers"
|
||||
import {} from "./section-dnd"
|
||||
import {} from "./constrain-drag-x"
|
||||
import { mergeWorktreeDiffs } from "../diff-viewer/diff-state"
|
||||
import { DiffScopeControls } from "../diff-viewer/DiffScopeControls"
|
||||
import { scopeCapabilities } from "./diff-scope-state"
|
||||
@@ -291,14 +289,6 @@ const AgentManagerContent: Component = () => {
|
||||
const evictLocal = (sid: string) =>
|
||||
setLocalSessionIDs((prev) => (prev.includes(sid) ? prev.filter((id) => id !== sid) : prev))
|
||||
const [sidebarWidth, setSidebarWidth] = createSignal(persisted?.sidebarWidth ?? DEFAULT_SIDEBAR_WIDTH)
|
||||
const sessionsCollapsed = () => registry.active().sessionsCollapsed() ?? true
|
||||
const setSessionsCollapsed = (v: Parameters<Setter<boolean>>[0]) =>
|
||||
registry.active().setSessionsCollapsed(typeof v === "function" ? v(sessionsCollapsed()) : v)
|
||||
const toggleSessions = () => {
|
||||
const collapsed = !sessionsCollapsed()
|
||||
setSessionsCollapsed(collapsed)
|
||||
vscode.postMessage({ type: "agentManager.setSessionsCollapsed", collapsed })
|
||||
}
|
||||
const sidebar = createSidebarCollapse(vscode, { initial: persisted?.sidebarCollapsed })
|
||||
const sidebarCollapsed = sidebar.collapsed
|
||||
const expandSidebar = sidebar.expand
|
||||
@@ -308,6 +298,26 @@ const AgentManagerContent: Component = () => {
|
||||
let sidebarRaf: number | undefined
|
||||
let pendingSidebarWidth: number | undefined
|
||||
const [history, setHistory] = createSignal(false)
|
||||
/** Project whose sessions the history view is scoped to (multi-project). */
|
||||
const [historyProject, setHistoryProject] = createSignal<string | undefined>()
|
||||
const closeHistory = () => {
|
||||
setHistory(false)
|
||||
setHistoryProject(undefined)
|
||||
}
|
||||
/** Open the sessions view; a project id scopes it and activates that project. */
|
||||
const openHistory = (pid?: string) => {
|
||||
const scoped = pid !== undefined && multiProject()
|
||||
if (scoped) {
|
||||
// Activating the target project first lets the shared session store and
|
||||
// the pick routing operate in that project only.
|
||||
vscode.postMessage({
|
||||
type: "agentManager.activateSelection",
|
||||
target: { projectId: pid, kind: "local" },
|
||||
} as never)
|
||||
}
|
||||
setHistoryProject(scoped ? pid : undefined)
|
||||
setHistory(true)
|
||||
}
|
||||
const [sidePanel, setSidePanel] = createSignal<SidePanelState>(null)
|
||||
const diffOpen = () => sidePanel() === SidePanel.Diff
|
||||
const prOpen = () => sidePanel() === SidePanel.PR
|
||||
@@ -326,7 +336,7 @@ const AgentManagerContent: Component = () => {
|
||||
const [panelWidth, setPanelWidth] = createSignal(clampPanelWidth(persisted?.sidePanelWidth, window.innerWidth))
|
||||
const resizeSide = createPanelResize(setPanelWidth, () => window.innerWidth)
|
||||
const showSideTerminal = () => {
|
||||
setHistory(false)
|
||||
closeHistory()
|
||||
setReviewActive(false)
|
||||
setSidePanel(SidePanel.Terminal)
|
||||
}
|
||||
@@ -349,7 +359,7 @@ const AgentManagerContent: Component = () => {
|
||||
currentProjectId,
|
||||
() => sidePanel() === SidePanel.Documents,
|
||||
() => {
|
||||
setHistory(false)
|
||||
closeHistory()
|
||||
setReviewActive(false)
|
||||
setSidePanel(SidePanel.Documents)
|
||||
},
|
||||
@@ -364,7 +374,7 @@ const AgentManagerContent: Component = () => {
|
||||
sync: (id, parentID) => session.syncSession(id, parentID, "inspector"),
|
||||
unsync: (id) => session.unsyncSession(id, "inspector"),
|
||||
show: () => {
|
||||
setHistory(false)
|
||||
closeHistory()
|
||||
setReviewActive(false)
|
||||
setSidePanel(SidePanel.Subagents)
|
||||
},
|
||||
@@ -578,7 +588,7 @@ const AgentManagerContent: Component = () => {
|
||||
}
|
||||
const openWindow = metrics.click("open_worktree_window", "tab_toolbar", openWorktreeDirectory)
|
||||
const togglePRPanel = () => {
|
||||
setHistory(false)
|
||||
closeHistory()
|
||||
if (reviewActive()) closeReviewTab()
|
||||
const opening = sidePanel() !== SidePanel.PR
|
||||
setSidePanel((prev) => (prev === SidePanel.PR ? null : SidePanel.PR))
|
||||
@@ -741,10 +751,6 @@ const AgentManagerContent: Component = () => {
|
||||
|
||||
const localSet = createMemo(() => new Set(localSessionIDs()))
|
||||
|
||||
const unassignedSessions = createMemo(() =>
|
||||
filterUnassignedSessions(session.sessions(), worktreeSessionIds(), localSet()),
|
||||
)
|
||||
|
||||
const projectSessionsLive = createProjectSessionsLive({
|
||||
base: projectLive.sessions,
|
||||
pid: currentProjectId,
|
||||
@@ -754,6 +760,15 @@ const AgentManagerContent: Component = () => {
|
||||
locals: localSet,
|
||||
})
|
||||
|
||||
/** Session ids shown in the project-scoped history view (every session of the project). */
|
||||
const historySessionIds = createMemo(() => {
|
||||
const pid = historyProject()
|
||||
if (!pid || !multiProject()) return undefined
|
||||
const sessions = projectSessionsLive()[pid]
|
||||
if (!sessions) return undefined
|
||||
return new Set(sessions.filter(isKnownRootSession).map((s) => s.id))
|
||||
})
|
||||
|
||||
const localSessions = createLocalSessions({
|
||||
ids: localSessionIDs,
|
||||
sessions: () => {
|
||||
@@ -913,14 +928,12 @@ const AgentManagerContent: Component = () => {
|
||||
|
||||
/** Flat visual order of all visible sidebar items — used for navigation and shortcut assignment. */
|
||||
const sidebarOrder = createMemo(() =>
|
||||
buildSidebarOrder(topLevelItems(), sortedWorktrees(), sections(), worktreesInSection, unassignedSessions()),
|
||||
buildSidebarOrder(topLevelItems(), sortedWorktrees(), sections(), worktreesInSection),
|
||||
)
|
||||
/** Map from sidebar item id → 1-based shortcut number (⌘1 for LOCAL, ⌘2 for first worktree, etc.) */
|
||||
const shortcutMap = createMemo(() => buildShortcutMap(sidebarOrder()))
|
||||
const projectShortcutMap = createMemo(() =>
|
||||
buildShortcutMap(
|
||||
buildProjectNavEntries(projectList(), projectStates(), projectLive.sessions()).map((entry) => ({ id: entry.id })),
|
||||
),
|
||||
buildShortcutMap(buildProjectNavEntries(projectList(), projectStates()).map((entry) => ({ id: entry.id }))),
|
||||
)
|
||||
|
||||
const moveToSection = (ids: string[], sec: string | null) =>
|
||||
@@ -939,18 +952,9 @@ const AgentManagerContent: Component = () => {
|
||||
|
||||
const scrollIntoView = (el: HTMLElement) => el.scrollIntoView({ block: "nearest", behavior: "smooth" })
|
||||
|
||||
const selectUnassigned = (id: string) => {
|
||||
saveTabMemory()
|
||||
setSelection(null)
|
||||
setReviewActive(false)
|
||||
session.selectSession(id)
|
||||
requestChatFocus(true)
|
||||
}
|
||||
|
||||
const focusSidebarItem = (item: { type: string; id: string }) => {
|
||||
if (item.type === "local") selectLocal()
|
||||
else if (item.type === "wt") selectWorktree(item.id)
|
||||
else selectUnassigned(item.id)
|
||||
requestChatFocus(true)
|
||||
const el = document.querySelector(`[data-sidebar-id="${item.id}"]`)
|
||||
if (el instanceof HTMLElement) scrollIntoView(el)
|
||||
@@ -963,7 +967,6 @@ const AgentManagerContent: Component = () => {
|
||||
focus: focusSidebarItem,
|
||||
projects: projectList,
|
||||
states: projectStates,
|
||||
sessions: projectLive.sessions,
|
||||
activeProjectId,
|
||||
selection,
|
||||
currentSessionID: session.currentSessionID,
|
||||
@@ -1027,7 +1030,7 @@ const AgentManagerContent: Component = () => {
|
||||
const current = managedSessions().find((entry) => entry.id === sid)
|
||||
if (current?.worktreeId) return focusManagedSession(current.worktreeId, sid)
|
||||
saveTabMemory()
|
||||
setHistory(false)
|
||||
closeHistory()
|
||||
setReviewActive(false)
|
||||
appendToTabOrder(sel, sid)
|
||||
evictLocal(sid)
|
||||
@@ -1037,7 +1040,7 @@ const AgentManagerContent: Component = () => {
|
||||
|
||||
const focusManagedSession = (worktreeId: string, sid: string) => {
|
||||
selectWorktree(worktreeId)
|
||||
setHistory(false)
|
||||
closeHistory()
|
||||
session.selectSession(sid)
|
||||
requestChatFocus()
|
||||
return true
|
||||
@@ -1063,7 +1066,7 @@ const AgentManagerContent: Component = () => {
|
||||
const focusSidebarSearchItem = (item: SidebarSearchItem) => {
|
||||
if (item.section?.collapsed)
|
||||
vscode.postMessage({ type: "agentManager.toggleSectionCollapsed", sectionId: item.section.id })
|
||||
setHistory(false)
|
||||
closeHistory()
|
||||
if (item.kind === "local") return selectLocal()
|
||||
if (item.kind === "worktree") return selectWorktree(item.worktreeId)
|
||||
if (item.location === "local") selectLocal()
|
||||
@@ -1156,7 +1159,7 @@ const AgentManagerContent: Component = () => {
|
||||
first: () => undefined,
|
||||
close: () => setReviewActive(false),
|
||||
hide: () => setSidePanel(null),
|
||||
history: () => setHistory(false),
|
||||
history: () => closeHistory(),
|
||||
reset: subagents.reset,
|
||||
})
|
||||
}
|
||||
@@ -1172,7 +1175,7 @@ const AgentManagerContent: Component = () => {
|
||||
onMount(() => {
|
||||
const handler = (event: MessageEvent) => {
|
||||
const msg = event.data
|
||||
if (msg?.type === "navigate" && msg.view === "history") return setHistory(true)
|
||||
if (msg?.type === "navigate" && msg.view === "history") return openHistory()
|
||||
if (msg?.type !== "action") return
|
||||
if (msg.action === "sessionPrevious") projectNav.step("up")
|
||||
else if (msg.action === "sessionNext") projectNav.step("down")
|
||||
@@ -1580,7 +1583,9 @@ const AgentManagerContent: Component = () => {
|
||||
applyProjectSelection(msg, {
|
||||
active: (projectId) => activeProjectId() === projectId,
|
||||
applied: (projectId) => currentProjectId() === projectId,
|
||||
managed: (projectId) => projectLive.sessions()[projectId] ?? projectStates()[projectId]?.sessions ?? [],
|
||||
// Managed state is the placement authority; the live listing can briefly
|
||||
// keep a stale worktree tag after a session moved back to local.
|
||||
managed: (projectId) => projectStates()[projectId]?.sessions ?? projectLive.sessions()[projectId] ?? [],
|
||||
local: () => selectLocal(),
|
||||
worktree: (projectId, worktreeId) => selectWorktree(worktreeId),
|
||||
focusLocal: focusLocalSession,
|
||||
@@ -1914,10 +1919,21 @@ const AgentManagerContent: Component = () => {
|
||||
vscode.postMessage({ type: "agentManager.openLocally", sessionId: sid })
|
||||
}
|
||||
|
||||
const openUnassigned = (id: string) => {
|
||||
metrics.track("open_session_locally", "unassigned_session_menu")
|
||||
openLocally(id)
|
||||
}
|
||||
/** History row menu: start a session in a new worktree or back in the project's local tabs. */
|
||||
const historyRowActions = historyRowActionsFactory({
|
||||
t,
|
||||
onPromote: (sessionId) => {
|
||||
metrics.track("promote_session", "history_row")
|
||||
closeHistory()
|
||||
vscode.postMessage({ type: "agentManager.promoteSession", sessionId })
|
||||
},
|
||||
onLocal: (sessionId) => {
|
||||
const pid = historyProject()
|
||||
if (pid) vscode.postMessage({ type: "agentManager.openSessionLocally", projectId: pid, sessionId } as never)
|
||||
else openLocally(sessionId)
|
||||
closeHistory()
|
||||
},
|
||||
})
|
||||
|
||||
const handleAddSession = () => {
|
||||
const sel = selection()
|
||||
@@ -2317,6 +2333,7 @@ const AgentManagerContent: Component = () => {
|
||||
t={t}
|
||||
onSearchRef={(ref) => (sidebarSearchMenu = ref)}
|
||||
onShortcuts={handleShowKeyboardShortcuts}
|
||||
onHistory={openHistory}
|
||||
shortcutMap={projectShortcutMap}
|
||||
/>
|
||||
</Show>
|
||||
@@ -2330,8 +2347,6 @@ const AgentManagerContent: Component = () => {
|
||||
isLocalBusy={isLocalBusy}
|
||||
repoBranch={repoBranch}
|
||||
localStats={localStats}
|
||||
sessionsCollapsed={sessionsCollapsed}
|
||||
toggleSessions={toggleSessions}
|
||||
search={{ items: sidebarSearch.items, current: sidebarSearch.current }}
|
||||
bindings={kb}
|
||||
defaultBranch={repoDefaultBranch}
|
||||
@@ -2345,6 +2360,7 @@ const AgentManagerContent: Component = () => {
|
||||
onNewWorktree={showNewWorktreeDialog}
|
||||
onNewSection={newSection}
|
||||
onShortcuts={metrics.click("keyboard_shortcuts", "worktrees_header", handleShowKeyboardShortcuts)}
|
||||
onHistory={() => openHistory()}
|
||||
projectId={activeProjectId()}
|
||||
sections={sections}
|
||||
sortedWorktrees={sortedWorktrees}
|
||||
@@ -2375,10 +2391,6 @@ const AgentManagerContent: Component = () => {
|
||||
confirmDeleteWorktree={confirmDeleteWorktree}
|
||||
handleDeleteWorktree={handleDeleteWorktree}
|
||||
confirmRemoveStaleWorktree={confirmRemoveStaleWorktree}
|
||||
unassignedSessions={unassignedSessions}
|
||||
selectUnassigned={selectUnassigned}
|
||||
promoteSession={promoteSession}
|
||||
openUnassigned={openUnassigned}
|
||||
track={metrics.click}
|
||||
/>
|
||||
</Show>
|
||||
@@ -2465,7 +2477,7 @@ const AgentManagerContent: Component = () => {
|
||||
<HistoryView
|
||||
onSelectSession={(id) => {
|
||||
if (addSessionToCurrentWorktree(id)) return
|
||||
setHistory(false)
|
||||
closeHistory()
|
||||
if (localSessionIDs().includes(id)) {
|
||||
saveTabMemory()
|
||||
session.selectSession(id)
|
||||
@@ -2483,8 +2495,10 @@ const AgentManagerContent: Component = () => {
|
||||
}
|
||||
openLocally(id)
|
||||
}}
|
||||
onBack={() => setHistory(false)}
|
||||
worktreeSessionIds={activeWorktreeSessionIds}
|
||||
onBack={closeHistory}
|
||||
worktreeSessionIds={historyProject() ? undefined : activeWorktreeSessionIds}
|
||||
sessionIds={historySessionIds}
|
||||
rowActions={historyRowActions}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={showDetailStack()}>
|
||||
@@ -2552,7 +2566,7 @@ const AgentManagerContent: Component = () => {
|
||||
}
|
||||
openLocally(id)
|
||||
}}
|
||||
onShowHistory={() => setHistory(true)}
|
||||
onShowHistory={() => openHistory()}
|
||||
onForkMessage={readOnly() ? undefined : handleForkSession}
|
||||
onForkSession={readOnly() ? undefined : handleForkSession}
|
||||
readonly={readOnly()}
|
||||
|
||||
@@ -47,6 +47,7 @@ interface Props {
|
||||
t: LanguageContextValue["t"]
|
||||
onSearchRef: (ref: SidebarSearchMenuRef) => void
|
||||
onShortcuts: () => void
|
||||
onHistory: (projectId: string) => void
|
||||
shortcutMap?: () => Map<string, number>
|
||||
}
|
||||
|
||||
@@ -202,6 +203,7 @@ export const ProjectList: Component<Props> = (props) => {
|
||||
})
|
||||
}
|
||||
onRemove={(projectId) => vscode.postMessage({ type: "agentManager.removeProject", projectId })}
|
||||
onHistory={props.onHistory}
|
||||
onExpand={(projectId, expanded) =>
|
||||
vscode.postMessage({ type: "agentManager.setProjectExpanded", projectId, expanded })
|
||||
}
|
||||
@@ -228,7 +230,6 @@ export const ProjectList: Component<Props> = (props) => {
|
||||
t={props.t}
|
||||
onSelectLocal={(projectId) => select({ projectId, kind: "local" })}
|
||||
onSelectWorktree={(projectId, worktreeId) => select({ projectId, kind: "worktree", worktreeId })}
|
||||
onSelectSession={(projectId, sessionId) => select({ projectId, kind: "session", sessionId })}
|
||||
onNewWorktree={newWorktree}
|
||||
shortcutMap={props.shortcutMap}
|
||||
/>
|
||||
|
||||
@@ -23,7 +23,6 @@ import { useVSCode } from "../src/context/vscode"
|
||||
import SectionHeader from "./SectionHeader"
|
||||
import { SidebarSectionHeader } from "./SidebarSectionHeader"
|
||||
import { WorktreeItem } from "./WorktreeItem"
|
||||
import { UnassignedSessionsSection } from "./UnassignedSessionsSection"
|
||||
import { ProjectActions } from "./ProjectActions"
|
||||
import { StatsSkeleton, WorktreeSkeleton } from "./Skeleton"
|
||||
import { applyTabOrder, firstOrderedTitle, reorderTabs } from "./tab-order"
|
||||
@@ -55,7 +54,6 @@ interface Props {
|
||||
t: LanguageContextValue["t"]
|
||||
onSelectLocal: (projectId: string) => void
|
||||
onSelectWorktree: (projectId: string, worktreeId: string) => void
|
||||
onSelectSession: (projectId: string, sessionId: string) => void
|
||||
onNewWorktree: (projectId: string) => void
|
||||
shortcutMap?: () => Map<string, number>
|
||||
}
|
||||
@@ -100,14 +98,11 @@ export const ProjectSidebarBody: Component<Props> = (props) => {
|
||||
const sections = () => store.sections()
|
||||
const worktrees = () => store.worktrees()
|
||||
const order = () => store.worktreeOrder()
|
||||
const localSessions = () => sessions(null)
|
||||
const sorted = createMemo(() => sortWorktrees(worktrees(), order()))
|
||||
const members = (sectionId: string) => sorted().filter((wt) => wt.sectionId === sectionId)
|
||||
const ungrouped = createMemo(() => sorted().filter((wt) => !wt.sectionId))
|
||||
const top = createMemo(() => buildTopLevelItems(sections(), ungrouped(), sorted(), order()))
|
||||
const sidebarOrder = createMemo(() =>
|
||||
projectSidebarOrder(top(), sorted(), sections(), members, state()?.sessionsCollapsed ? [] : localSessions()),
|
||||
)
|
||||
const sidebarOrder = createMemo(() => projectSidebarOrder(top(), sorted(), sections(), members))
|
||||
const post = (message: Record<string, unknown>) =>
|
||||
vscode.postMessage({ ...message, projectId: props.project.id } as never)
|
||||
|
||||
@@ -438,23 +433,6 @@ export const ProjectSidebarBody: Component<Props> = (props) => {
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UnassignedSessionsSection
|
||||
sessions={localSessions}
|
||||
loaded={() => props.sessions !== undefined && state() !== undefined}
|
||||
collapsed={() => state()?.sessionsCollapsed === true}
|
||||
disabled={state() === undefined}
|
||||
active={() => undefined}
|
||||
onToggle={() => {
|
||||
const current = state()
|
||||
if (!current) return
|
||||
post({ type: "agentManager.setSessionsCollapsed", collapsed: !current.sessionsCollapsed })
|
||||
}}
|
||||
onSelect={(sessionId) => props.onSelectSession(props.project.id, sessionId)}
|
||||
onPromote={(sessionId) => post({ type: "agentManager.promoteSession", sessionId })}
|
||||
onOpen={(sessionId) => post({ type: "agentManager.openLocally", sessionId })}
|
||||
sidebarId={(sessionId) => `${props.project.id}:sess:${sessionId}`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ interface ProjectsSectionProps {
|
||||
onSelect: (id: string) => void
|
||||
onRemove: (id: string) => void
|
||||
onExpand: (id: string, expanded: boolean) => void
|
||||
onHistory: (id: string) => void
|
||||
count: (id: string) => number | undefined
|
||||
tools?: JSX.Element
|
||||
body: (project: AgentProjectSnapshot) => JSX.Element
|
||||
@@ -69,18 +70,30 @@ export const ProjectsSection: Component<ProjectsSectionProps> = (props) => (
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<Show when={!project().pinned}>
|
||||
<div class="am-project-actions-row">
|
||||
<IconButton
|
||||
icon="close-small"
|
||||
icon="history"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
label={props.t("agentManager.project.remove")}
|
||||
aria-label={props.t("session.showHistory")}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
props.onRemove(project().id)
|
||||
props.onHistory(project().id)
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={!project().pinned}>
|
||||
<IconButton
|
||||
icon="close-small"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
label={props.t("agentManager.project.remove")}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
props.onRemove(project().id)
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
onToggle={() => {
|
||||
if (project().missing) return
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/** @jsxImportSource solid-js */
|
||||
|
||||
import type { Component } from "solid-js"
|
||||
import { IconButton } from "@kilocode/kilo-ui/icon-button"
|
||||
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
|
||||
import type { LanguageContextValue } from "../src/context/language"
|
||||
|
||||
interface Props {
|
||||
t: LanguageContextValue["t"]
|
||||
/** Move the session into a freshly created worktree. */
|
||||
onWorktree: () => void
|
||||
/** Move the session back to the project root and open it in the local tabs. */
|
||||
onLocal: () => void
|
||||
}
|
||||
|
||||
/** Direct hover actions for sessions in the Agent Manager history view. */
|
||||
export const SessionRowActions: Component<Props> = (props) => (
|
||||
<>
|
||||
<Tooltip value={props.t("agentManager.session.openInWorktree")} placement="right">
|
||||
<IconButton
|
||||
icon="branch"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
aria-label={props.t("agentManager.session.openInWorktree")}
|
||||
data-slot="session-row-action"
|
||||
onClick={props.onWorktree}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip value={props.t("agentManager.session.openLocally")} placement="right">
|
||||
<IconButton
|
||||
icon="local"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
aria-label={props.t("agentManager.session.openLocally")}
|
||||
data-slot="session-row-action"
|
||||
onClick={props.onLocal}
|
||||
/>
|
||||
</Tooltip>
|
||||
</>
|
||||
)
|
||||
@@ -15,7 +15,6 @@ import type {
|
||||
PRStatus,
|
||||
RunStatus,
|
||||
SectionState,
|
||||
SessionInfo,
|
||||
WorktreeGitStats,
|
||||
WorktreeState,
|
||||
} from "../src/types/messages"
|
||||
@@ -31,7 +30,6 @@ import SectionHeader from "./SectionHeader"
|
||||
import { SidebarSectionHeader } from "./SidebarSectionHeader"
|
||||
import { WorktreeItem } from "./WorktreeItem"
|
||||
import { WorktreeSectionActions } from "./WorktreeSectionActions"
|
||||
import { UnassignedSessionsSection } from "./UnassignedSessionsSection"
|
||||
import { StatsSkeleton, WorktreeSkeleton } from "./Skeleton"
|
||||
import type { SidebarSearchMenuRef } from "./SidebarSearchMenu"
|
||||
|
||||
@@ -48,8 +46,6 @@ export interface SidebarBodyProps {
|
||||
isLocalBusy: () => boolean
|
||||
repoBranch: () => string | undefined
|
||||
localStats: () => LocalGitStats | undefined
|
||||
sessionsCollapsed: () => boolean
|
||||
toggleSessions: () => void
|
||||
search: { items: () => SidebarSearchItem[]; current: () => SidebarSearchItem | undefined }
|
||||
bindings: () => Record<string, string>
|
||||
defaultBranch: () => string
|
||||
@@ -63,6 +59,7 @@ export interface SidebarBodyProps {
|
||||
onNewWorktree: () => void
|
||||
onNewSection: () => void
|
||||
onShortcuts: () => void
|
||||
onHistory: () => void
|
||||
sections: () => SectionState[]
|
||||
sortedWorktrees: () => WorktreeState[]
|
||||
worktrees: () => WorktreeState[]
|
||||
@@ -92,10 +89,6 @@ export interface SidebarBodyProps {
|
||||
confirmDeleteWorktree: (id: string) => void
|
||||
handleDeleteWorktree: (id: string, e: MouseEvent) => void
|
||||
confirmRemoveStaleWorktree: (id: string) => void
|
||||
unassignedSessions: () => SessionInfo[]
|
||||
selectUnassigned: (id: string) => void
|
||||
promoteSession: (id: string) => void
|
||||
openUnassigned: (id: string) => void
|
||||
track: (event: string, source: string, action: () => void) => () => void
|
||||
}
|
||||
|
||||
@@ -184,7 +177,7 @@ export const SidebarBody: Component<SidebarBodyProps> = (props) => {
|
||||
</button>
|
||||
|
||||
{/* WORKTREES section */}
|
||||
<div class={`am-section ${props.sessionsCollapsed() ? "am-section-grow" : ""}`}>
|
||||
<div class="am-section am-section-grow">
|
||||
<SidebarSectionHeader
|
||||
class="am-section-header"
|
||||
label={<span class="am-section-label">{props.t("agentManager.section.worktrees")}</span>}
|
||||
@@ -203,6 +196,7 @@ export const SidebarBody: Component<SidebarBodyProps> = (props) => {
|
||||
onNew={props.onNewWorktree}
|
||||
onSection={props.onNewSection}
|
||||
onShortcuts={props.onShortcuts}
|
||||
onHistory={props.onHistory}
|
||||
onSettings={() =>
|
||||
vscode.postMessage({ type: "openSettingsPanel", tab: "agentManager", projectId: props.projectId })
|
||||
}
|
||||
@@ -448,17 +442,6 @@ export const SidebarBody: Component<SidebarBodyProps> = (props) => {
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UnassignedSessionsSection
|
||||
sessions={props.unassignedSessions}
|
||||
loaded={props.sessionsLoaded}
|
||||
collapsed={props.sessionsCollapsed}
|
||||
active={() => (props.selection() === null ? props.currentSessionID() : undefined)}
|
||||
onToggle={props.toggleSessions}
|
||||
onSelect={props.selectUnassigned}
|
||||
onPromote={props.promoteSession}
|
||||
onOpen={props.openUnassigned}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import { For, Show, type Accessor, type Component } from "solid-js"
|
||||
import { ContextMenu } from "@kilocode/kilo-ui/context-menu"
|
||||
import { Icon } from "@kilocode/kilo-ui/icon"
|
||||
import { IconButton } from "@kilocode/kilo-ui/icon-button"
|
||||
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
|
||||
import type { SessionInfo } from "../src/types/messages"
|
||||
import { useLanguage } from "../src/context/language"
|
||||
import { formatRelativeDate } from "../src/utils/date"
|
||||
import { SidebarSectionHeader } from "./SidebarSectionHeader"
|
||||
import { SessionSkeleton } from "./Skeleton"
|
||||
|
||||
interface Props {
|
||||
sessions: Accessor<SessionInfo[]>
|
||||
loaded: Accessor<boolean>
|
||||
collapsed: Accessor<boolean>
|
||||
disabled?: boolean
|
||||
active: Accessor<string | undefined>
|
||||
onToggle: () => void
|
||||
onSelect: (id: string) => void
|
||||
onPromote: (id: string) => void
|
||||
onOpen: (id: string) => void
|
||||
sidebarId?: (id: string) => string
|
||||
}
|
||||
|
||||
export const UnassignedSessionsSection: Component<Props> = (props) => {
|
||||
const { t } = useLanguage()
|
||||
|
||||
const promote = (id: string, event: MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
props.onPromote(id)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class={`am-section ${props.collapsed() ? "" : "am-section-grow"}`}>
|
||||
<SidebarSectionHeader
|
||||
class="am-section-header am-section-toggle"
|
||||
expanded={!props.collapsed()}
|
||||
disabled={props.disabled}
|
||||
ariaLabel={t("agentManager.section.sessions")}
|
||||
label={<span class="am-section-label">{t("agentManager.section.sessions")}</span>}
|
||||
onToggle={props.onToggle}
|
||||
/>
|
||||
<Show when={!props.collapsed()}>
|
||||
<div class="am-list">
|
||||
<Show when={props.loaded()} fallback={<SessionSkeleton />}>
|
||||
<For each={props.sessions()}>
|
||||
{(session) => (
|
||||
<ContextMenu>
|
||||
<ContextMenu.Trigger as="div" style={{ display: "contents" }}>
|
||||
<button
|
||||
class={`am-item ${session.id === props.active() ? "am-item-active" : ""}`}
|
||||
data-sidebar-id={props.sidebarId?.(session.id) ?? session.id}
|
||||
onClick={() => props.onSelect(session.id)}
|
||||
>
|
||||
<span class="am-item-title" dir="auto">
|
||||
{session.title || t("agentManager.session.untitled")}
|
||||
</span>
|
||||
<span class="am-item-time">{formatRelativeDate(session.updatedAt)}</span>
|
||||
<div class="am-item-promote">
|
||||
<Tooltip value={t("agentManager.session.openInWorktree")} placement="right">
|
||||
<IconButton
|
||||
icon="branch"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
label={t("agentManager.session.openInWorktree")}
|
||||
onClick={(event: MouseEvent) => promote(session.id, event)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</button>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Portal>
|
||||
<ContextMenu.Content class="am-ctx-menu">
|
||||
<ContextMenu.Item onSelect={() => props.onPromote(session.id)}>
|
||||
<Icon name="branch" size="small" />
|
||||
<ContextMenu.ItemLabel>{t("agentManager.session.openInWorktree")}</ContextMenu.ItemLabel>
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item onSelect={() => props.onOpen(session.id)}>
|
||||
<Icon name="folder" size="small" />
|
||||
<ContextMenu.ItemLabel>{t("agentManager.session.openLocally")}</ContextMenu.ItemLabel>
|
||||
</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Portal>
|
||||
</ContextMenu>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { Show } from "solid-js"
|
||||
import { DropdownMenu } from "@kilocode/kilo-ui/dropdown-menu"
|
||||
import { Icon } from "@kilocode/kilo-ui/icon"
|
||||
import { IconButton } from "@kilocode/kilo-ui/icon-button"
|
||||
import { TooltipKeybind } from "@kilocode/kilo-ui/tooltip"
|
||||
import { Tooltip, TooltipKeybind } from "@kilocode/kilo-ui/tooltip"
|
||||
import type { LanguageContextValue } from "../src/context/language"
|
||||
import { parseBindingTokens } from "./keybind-tokens"
|
||||
import { SidebarSearchMenu, type SidebarSearchMenuRef } from "./SidebarSearchMenu"
|
||||
@@ -26,6 +26,7 @@ interface WorktreeSectionActionsProps {
|
||||
onSection: () => void
|
||||
onShortcuts: () => void
|
||||
onSettings: () => void
|
||||
onHistory: () => void
|
||||
}
|
||||
|
||||
export const WorktreeSectionActions: Component<WorktreeSectionActionsProps> = (props) => (
|
||||
@@ -107,6 +108,15 @@ export const WorktreeSectionActions: Component<WorktreeSectionActionsProps> = (p
|
||||
onClick={props.onShortcuts}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
<Tooltip value={props.t("session.showHistory")} placement="bottom">
|
||||
<IconButton
|
||||
icon="history"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
aria-label={props.t("session.showHistory")}
|
||||
onClick={props.onHistory}
|
||||
/>
|
||||
</Tooltip>
|
||||
<IconButton
|
||||
icon="settings-gear"
|
||||
size="small"
|
||||
|
||||
@@ -415,12 +415,6 @@ html[data-theme="kilo-vscode"]
|
||||
animation: am-skeleton-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Collapsible section toggle */
|
||||
|
||||
.am-section-toggle:hover .am-section-label {
|
||||
color: var(--text-base);
|
||||
}
|
||||
|
||||
.am-section-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1200,88 +1194,6 @@ html[data-theme="kilo-vscode"]
|
||||
border-left-color: var(--border-focus, #007fd4);
|
||||
}
|
||||
|
||||
/* Session list */
|
||||
|
||||
.am-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/* Session items */
|
||||
|
||||
.am-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
color: var(--text-base);
|
||||
font-family: inherit;
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
.am-item:hover {
|
||||
background: var(--surface-inset-base-hover);
|
||||
}
|
||||
|
||||
.am-item:active {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.am-item-active {
|
||||
background: var(--surface-interactive-base);
|
||||
color: var(--text-on-interactive-base);
|
||||
}
|
||||
|
||||
.am-item-title {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.am-item-time {
|
||||
font-size: var(--font-size-small);
|
||||
white-space: nowrap;
|
||||
color: var(--text-weaker);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.am-item-active .am-item-time {
|
||||
color: var(--text-on-interactive-base);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Promote button on session rows */
|
||||
|
||||
.am-item-promote {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.am-item:hover .am-item-time {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.am-item:hover .am-item-promote {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Create worktree button (empty state) */
|
||||
|
||||
.am-worktree-create {
|
||||
@@ -2803,6 +2715,14 @@ body.am-wt-dragging-active * {
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
/* Per-project row actions (history, remove) */
|
||||
|
||||
.am-project-actions-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/* Fixed-width slot so menu items with and without a check mark align. */
|
||||
.am-menu-check {
|
||||
display: flex;
|
||||
@@ -3666,7 +3586,7 @@ body.am-wt-dragging-active * {
|
||||
animation: am-skeleton-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Session skeleton — matches .am-item layout */
|
||||
/* Session skeleton */
|
||||
.am-skeleton-session {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -4624,14 +4544,12 @@ body.vscode-high-contrast-light {
|
||||
|
||||
/* Sidebar items */
|
||||
.am-local-item,
|
||||
.am-worktree-item,
|
||||
.am-item {
|
||||
.am-worktree-item {
|
||||
border: 1px solid var(--vscode-contrastBorder, transparent);
|
||||
}
|
||||
|
||||
.am-local-item-active,
|
||||
.am-worktree-item-active,
|
||||
.am-item-active {
|
||||
.am-worktree-item-active {
|
||||
border-color: var(--vscode-contrastBorder, transparent);
|
||||
}
|
||||
|
||||
@@ -4808,7 +4726,6 @@ body.vscode-high-contrast-light {
|
||||
/* Focus states */
|
||||
.am-local-item:focus-visible,
|
||||
.am-worktree-item:focus-visible,
|
||||
.am-item:focus-visible,
|
||||
.am-tab:focus-visible,
|
||||
.am-worktree-create:focus-visible,
|
||||
.am-nv-pill:focus-visible,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/** @jsxImportSource solid-js */
|
||||
|
||||
import type { JSX } from "solid-js"
|
||||
import type { LanguageContextValue } from "../src/context/language"
|
||||
import type { SessionInfo } from "../src/types/messages"
|
||||
import { SessionRowActions } from "./SessionRowActions"
|
||||
|
||||
/**
|
||||
* Per-row actions for the Agent Manager sessions view.
|
||||
* `onLocal` and `onPromote` close over the app's scoped-project handlers.
|
||||
*/
|
||||
export function historyRowActions(opts: {
|
||||
t: LanguageContextValue["t"]
|
||||
onPromote: (sessionId: string) => void
|
||||
onLocal: (sessionId: string) => void
|
||||
}): (entry: SessionInfo) => JSX.Element {
|
||||
return (entry) => (
|
||||
<SessionRowActions t={opts.t} onWorktree={() => opts.onPromote(entry.id)} onLocal={() => opts.onLocal(entry.id)} />
|
||||
)
|
||||
}
|
||||
@@ -25,16 +25,6 @@ export function canOpenRootSession(id: string, sessions: Pick<SessionLike, "id"
|
||||
return !!session && isKnownRootSession(session)
|
||||
}
|
||||
|
||||
export function filterUnassignedSessions<T extends SessionLike>(
|
||||
sessions: T[],
|
||||
worktree: Set<string>,
|
||||
local: Set<string>,
|
||||
): T[] {
|
||||
return [...sessions]
|
||||
.filter((s) => isKnownRootSession(s) && !worktree.has(s.id) && !local.has(s.id))
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
}
|
||||
|
||||
export function resolveNavigation(direction: "up" | "down", current: string | undefined, ids: string[]): NavResult {
|
||||
// Determine current position: -1 = local, 0..N-1 = session index
|
||||
if (!current) {
|
||||
@@ -166,22 +156,18 @@ export interface ProjectNavInput {
|
||||
/** Persisted top-level order containing worktree and section IDs. */
|
||||
worktreeOrder?: string[]
|
||||
sections: { id: string; collapsed: boolean }[]
|
||||
sessionsCollapsed: boolean
|
||||
/** Visible unassigned (root, no worktree) sessions in render order. */
|
||||
unassigned: { id: string }[]
|
||||
}
|
||||
|
||||
export const localNavId = (projectId: string) => `${projectId}:local`
|
||||
export const worktreeNavId = (projectId: string, worktreeId: string) => `${projectId}:wt:${worktreeId}`
|
||||
export const sessionNavId = (projectId: string, sessionId: string) => `${projectId}:sess:${sessionId}`
|
||||
|
||||
/**
|
||||
* Build one global visual order across expanded projects.
|
||||
*
|
||||
* For each expanded project (in input order): Local, then ungrouped worktrees,
|
||||
* then members of each non-collapsed section in top-level order, then visible
|
||||
* unassigned sessions. This matches `buildTopLevelItems` and the project body.
|
||||
* Collapsed projects contribute nothing.
|
||||
* then members of each non-collapsed section in top-level order. This matches
|
||||
* `buildTopLevelItems` and the project body. Collapsed projects contribute
|
||||
* nothing.
|
||||
*/
|
||||
export function buildProjectNavOrder(projects: ProjectNavInput[]): NavEntry[] {
|
||||
const order: NavEntry[] = []
|
||||
@@ -211,11 +197,6 @@ export function buildProjectNavOrder(projects: ProjectNavInput[]): NavEntry[] {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!p.sessionsCollapsed) {
|
||||
for (const s of p.unassigned) {
|
||||
order.push({ id: sessionNavId(pid, s.id), target: { projectId: pid, kind: "session", sessionId: s.id } })
|
||||
}
|
||||
}
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
@@ -6,24 +6,26 @@ import {
|
||||
resolveProjectNav,
|
||||
localNavId,
|
||||
worktreeNavId,
|
||||
sessionNavId,
|
||||
type NavEntry,
|
||||
type NavTarget,
|
||||
LOCAL,
|
||||
} from "./navigate"
|
||||
import type { SidebarItem } from "./section-helpers"
|
||||
import type { AgentManagerStateMessage, AgentProjectSnapshot, ProjectSessionInfo } from "../src/types/messages"
|
||||
import type { AgentManagerStateMessage, AgentProjectSnapshot } from "../src/types/messages"
|
||||
|
||||
/**
|
||||
* Sidebar keyboard-nav controller for the Agent Manager.
|
||||
*
|
||||
* Handles previous/next (⌘⌥↑/↓) and numeric-shortcut (⌘1-9) navigation across
|
||||
* the sidebar. In multi-project mode it builds one global visual order across
|
||||
* every expanded project — Local, ungrouped worktrees, then section members,
|
||||
* and visible unassigned sessions — using stable project-qualified composite
|
||||
* ids, and activates each target with a single atomic
|
||||
* `agentManager.activateSelection` dispatch. In single-project mode it keeps
|
||||
* the legacy in-process traversal over the active project's flat sidebar order.
|
||||
* every expanded project — Local, ungrouped worktrees, then section members —
|
||||
* using stable project-qualified composite ids, and activates each target with
|
||||
* a single atomic `agentManager.activateSelection` dispatch. In single-project
|
||||
* mode it keeps the legacy in-process traversal over the active project's flat
|
||||
* sidebar order.
|
||||
*
|
||||
* Sessions are reachable through the history view, not the tree, so they are
|
||||
* not part of the nav order.
|
||||
*
|
||||
* The pure order/resolution logic lives in {@link navigate.ts} so it stays
|
||||
* solid/DOM-free and unit-testable; this module owns the reactive wiring and
|
||||
@@ -31,13 +33,12 @@ import type { AgentManagerStateMessage, AgentProjectSnapshot, ProjectSessionInfo
|
||||
*/
|
||||
export interface ProjectNavDeps {
|
||||
multiProject: Accessor<boolean>
|
||||
/** Legacy flat sidebar order for single-project mode (LOCAL, worktrees, sessions). */
|
||||
/** Legacy flat sidebar order for single-project mode (LOCAL, worktrees). */
|
||||
sidebarOrder: Accessor<SidebarItem[]>
|
||||
/** Legacy in-process activator for single-project mode. */
|
||||
focus: (item: SidebarItem) => void
|
||||
projects: Accessor<AgentProjectSnapshot[]>
|
||||
states: Accessor<Record<string, AgentManagerStateMessage>>
|
||||
sessions: Accessor<Record<string, ProjectSessionInfo[]>>
|
||||
activeProjectId: Accessor<string | undefined>
|
||||
selection: Accessor<typeof LOCAL | string | null>
|
||||
currentSessionID: Accessor<string | undefined>
|
||||
@@ -52,13 +53,12 @@ export interface ProjectNav {
|
||||
export function buildProjectNavEntries(
|
||||
projects: AgentProjectSnapshot[],
|
||||
states: Record<string, AgentManagerStateMessage>,
|
||||
sessions: Record<string, ProjectSessionInfo[]>,
|
||||
): NavEntry[] {
|
||||
return buildProjectNavOrder(
|
||||
projects.map((p) => {
|
||||
const st = states[p.id]
|
||||
if (!st) {
|
||||
return { id: p.id, expanded: false, worktrees: [], sections: [], sessionsCollapsed: false, unassigned: [] }
|
||||
return { id: p.id, expanded: false, worktrees: [], sections: [] }
|
||||
}
|
||||
return {
|
||||
id: p.id,
|
||||
@@ -66,8 +66,6 @@ export function buildProjectNavEntries(
|
||||
worktrees: (st.worktrees ?? []).map((w) => ({ id: w.id, sectionId: w.sectionId, groupId: w.groupId })),
|
||||
worktreeOrder: st.worktreeOrder,
|
||||
sections: (st.sections ?? []).map((s) => ({ id: s.id, collapsed: s.collapsed })),
|
||||
sessionsCollapsed: st.sessionsCollapsed === true,
|
||||
unassigned: (sessions[p.id] ?? []).filter((s) => s.worktreeId === null).map((s) => ({ id: s.id })),
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -77,7 +75,7 @@ export function buildProjectNavEntries(
|
||||
export const navSelector = (target: NavTarget): string => {
|
||||
if (target.kind === "local") return `[data-sidebar-id="${target.projectId}:local"]`
|
||||
if (target.kind === "worktree") return `[data-sidebar-id="${target.projectId}:${target.worktreeId}"]`
|
||||
return `[data-sidebar-id="${sessionNavId(target.projectId, target.sessionId)}"]`
|
||||
return `[data-sidebar-id="${target.projectId}:sess:${target.sessionId}"]`
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,9 +95,7 @@ export function createProjectNav(
|
||||
): ProjectNav {
|
||||
const projectOrder = createMemo((): NavEntry[] => {
|
||||
if (!deps.multiProject()) return []
|
||||
const states = deps.states()
|
||||
const live = deps.sessions()
|
||||
return buildProjectNavEntries(deps.projects(), states, live)
|
||||
return buildProjectNavEntries(deps.projects(), deps.states())
|
||||
})
|
||||
|
||||
const currentId = createMemo((): string | undefined => {
|
||||
@@ -109,10 +105,8 @@ export function createProjectNav(
|
||||
const sel = deps.selection()
|
||||
if (sel === LOCAL) return localNavId(pid)
|
||||
if (typeof sel === "string") return worktreeNavId(pid, sel)
|
||||
if (sel === null) {
|
||||
const sid = deps.currentSessionID()
|
||||
if (sid) return sessionNavId(pid, sid)
|
||||
}
|
||||
// A null selection with an open local session is "on local" for nav.
|
||||
if (sel === null && deps.currentSessionID()) return localNavId(pid)
|
||||
return undefined
|
||||
})
|
||||
|
||||
|
||||
@@ -111,14 +111,13 @@ export function buildTopLevelItems(
|
||||
/**
|
||||
* Build the flat visual order of all sidebar items matching what the user sees.
|
||||
* LOCAL is always first, then worktrees in visual order (ungrouped first, then sections,
|
||||
* skipping collapsed sections), then unassigned sessions.
|
||||
* skipping collapsed sections). Sessions are reachable through the history view, not the tree.
|
||||
*/
|
||||
export function buildSidebarOrder(
|
||||
items: TopLevelItem[],
|
||||
sorted: WorktreeState[],
|
||||
sections: SectionState[],
|
||||
members: (id: string) => WorktreeState[],
|
||||
sessions: { id: string }[],
|
||||
): SidebarItem[] {
|
||||
const result: SidebarItem[] = [{ type: "local", id: "local" }]
|
||||
if (sections.length > 0) {
|
||||
@@ -138,9 +137,6 @@ export function buildSidebarOrder(
|
||||
result.push({ type: "wt", id: wt.id })
|
||||
}
|
||||
}
|
||||
for (const s of sessions) {
|
||||
result.push({ type: "session", id: s.id })
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Contains a source tab bar and an always-visible "Import session" button.
|
||||
*/
|
||||
|
||||
import { Component, Show, createEffect, createSignal, onCleanup, type Accessor } from "solid-js"
|
||||
import { Component, Show, createEffect, createSignal, onCleanup, type Accessor, type JSX } from "solid-js"
|
||||
import { Button } from "@kilocode/kilo-ui/button"
|
||||
import { useDialog } from "@kilocode/kilo-ui/context/dialog"
|
||||
import { useLanguage } from "../../context/language"
|
||||
@@ -13,11 +13,16 @@ import { useLocalTabs } from "../../context/local-tabs"
|
||||
import { CloudImportDialog } from "../chat/CloudImportDialog"
|
||||
import SessionList from "./SessionList"
|
||||
import CloudSessionList from "./CloudSessionList"
|
||||
import type { SessionInfo } from "../../types/messages"
|
||||
|
||||
interface HistoryViewProps {
|
||||
onSelectSession: (id: string) => void
|
||||
onBack?: () => void
|
||||
worktreeSessionIds?: Accessor<ReadonlySet<string> | undefined>
|
||||
/** Filter the Local tab to these session ids. */
|
||||
sessionIds?: Accessor<ReadonlySet<string> | undefined>
|
||||
/** Extra per-row actions rendered in the Local tab. */
|
||||
rowActions?: (session: SessionInfo) => JSX.Element
|
||||
}
|
||||
|
||||
type Source = "local" | "cloud" | "worktree"
|
||||
@@ -159,7 +164,13 @@ const HistoryView: Component<HistoryViewProps> = (props) => {
|
||||
aria-labelledby="history-tab-local"
|
||||
hidden={tab() !== "local"}
|
||||
>
|
||||
{tab() === "local" && <SessionList onSelectSession={props.onSelectSession} />}
|
||||
{tab() === "local" && (
|
||||
<SessionList
|
||||
onSelectSession={props.onSelectSession}
|
||||
sessionIds={props.sessionIds}
|
||||
rowActions={props.rowActions}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
class="history-view-content"
|
||||
|
||||
@@ -38,7 +38,9 @@ function dateGroupKey(iso: string): (typeof DATE_GROUP_KEYS)[number] {
|
||||
|
||||
interface SessionListProps {
|
||||
onSelectSession: (id: string) => void
|
||||
sessionIds?: Accessor<ReadonlySet<string>>
|
||||
sessionIds?: Accessor<ReadonlySet<string> | undefined>
|
||||
/** Extra per-row actions rendered after rename/delete (e.g. Agent Manager menus). */
|
||||
rowActions?: (session: SessionInfo) => JSX.Element
|
||||
}
|
||||
|
||||
const SessionList: Component<SessionListProps> = (props) => {
|
||||
@@ -159,6 +161,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
aria-label={label(language.t("session.delete.title"), item)}
|
||||
onClick={(event) => confirmDelete(item, event.currentTarget)}
|
||||
/>
|
||||
<Show when={props.rowActions}>{props.rowActions?.(item)}</Show>
|
||||
</>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -1550,6 +1550,7 @@ export const MultiProjectSidebar: Story = {
|
||||
t={t}
|
||||
onSearchRef={() => {}}
|
||||
onShortcuts={() => {}}
|
||||
onHistory={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</StoryProviders>
|
||||
|
||||
@@ -697,6 +697,13 @@ export interface OpenLocallyRequest {
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
// Move a worktree-bound session back to the project root and open it in the local tabs
|
||||
export interface OpenSessionLocallyRequest {
|
||||
type: "agentManager.openSessionLocally"
|
||||
projectId?: string
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
// Add a new session to an existing worktree
|
||||
export interface AddSessionToWorktreeRequest {
|
||||
type: "agentManager.addSessionToWorktree"
|
||||
@@ -1601,6 +1608,7 @@ export type WebviewMessage =
|
||||
| RemoveStaleWorktreeRequest
|
||||
| PromoteSessionRequest
|
||||
| OpenLocallyRequest
|
||||
| OpenSessionLocallyRequest
|
||||
| AddSessionToWorktreeRequest
|
||||
| ForkSessionRequest
|
||||
| SidebarForkSessionRequest
|
||||
|
||||
Reference in New Issue
Block a user