feat(agent-manager): add fixed local session tab with branch display and persistence (#446)

* tmp

* tmp

* tmp

* feat(agent-manager): add fixed local session tab with branch display and persistence

Add a dedicated local session item at the top of the agent manager sidebar
that always runs in the main repo (never a worktree). The local session
persists across panel reopens via vscode webview state, shows the current
git branch name (refreshed on click), and supports keyboard navigation.

Extract navigation logic into a pure testable function with 16 unit tests.

* fix(agent-manager): invalidate stale persisted localSessionID on recovery

The local session ID was persisted to webview state but never checked
against the actual sessions list on recovery. If the session was deleted
or expired between VS Code reloads, the UI would get stuck selecting a
ghost session with no way to recover.
This commit is contained in:
Marius
2026-02-19 11:49:15 +01:00
committed by GitHub
parent 32e04107ea
commit 46e06264da
8 changed files with 410 additions and 29 deletions
@@ -68,6 +68,7 @@ export class AgentManagerProvider implements vscode.Disposable {
})
void this.recoverWorktrees()
void this.sendRepoInfo()
this.panel.onDidDispose(() => {
this.log("Panel disposed")
@@ -86,6 +87,10 @@ export class AgentManagerProvider implements vscode.Disposable {
// Custom agent-manager messages -- consumed here, never reach KiloProvider
if (type === "agentManager.createWorktreeSession") return this.onCreateWorktreeSession(msg)
if (type === "agentManager.requestRepoInfo") {
void this.sendRepoInfo()
return null
}
// After clearSession, re-register worktree sessions so SSE events keep flowing
if (type === "clearSession") {
@@ -222,6 +227,21 @@ export class AgentManagerProvider implements vscode.Disposable {
return null
}
// ---------------------------------------------------------------------------
// Repo info
// ---------------------------------------------------------------------------
private async sendRepoInfo(): Promise<void> {
const mgr = this.getWorktreeManager()
if (!mgr) return
try {
const branch = await mgr.currentBranch()
this.postToWebview({ type: "agentManager.repoInfo", branch })
} catch (error) {
this.log(`Failed to get current branch: ${error}`)
}
}
// ---------------------------------------------------------------------------
// Worktree management
// ---------------------------------------------------------------------------
@@ -270,7 +270,7 @@ export class WorktreeManager {
}
}
private async currentBranch(): Promise<string> {
async currentBranch(): Promise<string> {
return (await this.git.revparse(["--abbrev-ref", "HEAD"])).trim()
}
@@ -0,0 +1,136 @@
import { describe, it, expect } from "bun:test"
import { resolveNavigation, validateLocalSession } from "../../webview-ui/agent-manager/navigate"
const ids = ["a", "b", "c", "d"]
describe("resolveNavigation", () => {
describe("from local (current = undefined)", () => {
it("down → selects first session", () => {
expect(resolveNavigation("down", undefined, ids)).toEqual({ action: "select", id: "a" })
})
it("up → none (already at top)", () => {
expect(resolveNavigation("up", undefined, ids)).toEqual({ action: "none" })
})
it("down with empty list → none", () => {
expect(resolveNavigation("down", undefined, [])).toEqual({ action: "none" })
})
it("up with empty list → none", () => {
expect(resolveNavigation("up", undefined, [])).toEqual({ action: "none" })
})
})
describe("from first session", () => {
it("up → local", () => {
expect(resolveNavigation("up", "a", ids)).toEqual({ action: "local" })
})
it("down → selects second session", () => {
expect(resolveNavigation("down", "a", ids)).toEqual({ action: "select", id: "b" })
})
})
describe("from middle session", () => {
it("up → selects previous session", () => {
expect(resolveNavigation("up", "b", ids)).toEqual({ action: "select", id: "a" })
})
it("down → selects next session", () => {
expect(resolveNavigation("down", "b", ids)).toEqual({ action: "select", id: "c" })
})
})
describe("from last session", () => {
it("down → none (already at bottom)", () => {
expect(resolveNavigation("down", "d", ids)).toEqual({ action: "none" })
})
it("up → selects previous session", () => {
expect(resolveNavigation("up", "d", ids)).toEqual({ action: "select", id: "c" })
})
})
describe("current session not in list", () => {
it("down → none", () => {
expect(resolveNavigation("down", "unknown", ids)).toEqual({ action: "none" })
})
it("up → none", () => {
expect(resolveNavigation("up", "unknown", ids)).toEqual({ action: "none" })
})
})
describe("single session list", () => {
it("down from local → selects only session", () => {
expect(resolveNavigation("down", undefined, ["x"])).toEqual({ action: "select", id: "x" })
})
it("up from only session → local", () => {
expect(resolveNavigation("up", "x", ["x"])).toEqual({ action: "local" })
})
it("down from only session → none", () => {
expect(resolveNavigation("down", "x", ["x"])).toEqual({ action: "none" })
})
})
describe("sequential walk-through", () => {
it("navigating down through entire list then back up returns to local", () => {
const sessions = ["s1", "s2", "s3"]
const trail: string[] = []
// Start at local, navigate down through all sessions
let current: string | undefined = undefined
for (let i = 0; i < 4; i++) {
const result = resolveNavigation("down", current, sessions)
if (result.action === "select") {
current = result.id
trail.push(current)
} else {
break
}
}
expect(trail).toEqual(["s1", "s2", "s3"])
// Navigate back up through all sessions to local
const upTrail: (string | "local")[] = []
for (let i = 0; i < 4; i++) {
const result = resolveNavigation("up", current, sessions)
if (result.action === "select") {
current = result.id
upTrail.push(current)
} else if (result.action === "local") {
current = undefined
upTrail.push("local")
} else {
break
}
}
expect(upTrail).toEqual(["s2", "s1", "local"])
})
})
})
describe("validateLocalSession", () => {
it("returns the ID when it exists in the sessions list", () => {
expect(validateLocalSession("abc", ["abc", "def"])).toBe("abc")
})
it("returns undefined when the ID is not in the sessions list (stale/deleted)", () => {
expect(validateLocalSession("gone", ["abc", "def"])).toBeUndefined()
})
it("returns undefined when sessions list is empty", () => {
expect(validateLocalSession("abc", [])).toBeUndefined()
})
it("returns undefined when persisted ID is undefined", () => {
expect(validateLocalSession(undefined, ["abc"])).toBeUndefined()
})
it("returns undefined when both are empty/undefined", () => {
expect(validateLocalSession(undefined, [])).toBeUndefined()
})
})
@@ -4,6 +4,7 @@ import { Component, For, Show, createSignal, createEffect, createMemo, onMount,
import type {
ExtensionMessage,
AgentManagerSessionMetaMessage,
AgentManagerRepoInfoMessage,
AgentManagerWorktreeSetupMessage,
} from "../src/types/messages"
import { ThemeProvider } from "@kilocode/kilo-ui/theme"
@@ -26,6 +27,7 @@ import { WorktreeModeProvider, useWorktreeMode, type SessionMode } from "../src/
import { ChatView } from "../src/components/chat"
import { LanguageBridge, DataBridge } from "../src/App"
import { formatRelativeDate } from "../src/utils/date"
import { resolveNavigation, validateLocalSession } from "./navigate"
import "./agent-manager.css"
interface WorktreeMeta {
@@ -49,19 +51,52 @@ const AgentManagerContent: Component = () => {
const [sessionMeta, setSessionMeta] = createSignal<Record<string, WorktreeMeta>>({})
const [setup, setSetup] = createSignal<SetupState>({ active: false, message: "" })
const [repoBranch, setRepoBranch] = createSignal<string | undefined>()
// Recover persisted local session ID from webview state
const persisted = vscode.getState<{ localSessionID?: string }>()
const [localSessionID, setLocalSessionID] = createSignal<string | undefined>(persisted?.localSessionID)
// Whether the user is viewing the local workspace
const [onLocal, setOnLocal] = createSignal(true)
const isLocal = () => {
const lid = localSessionID()
const current = session.currentSessionID()
if (onLocal() && !current) return true
if (lid && current === lid) return true
return false
}
// Sessions list excludes the local session
const sorted = createMemo(() =>
[...session.sessions()].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()),
[...session.sessions()]
.filter((s) => s.id !== localSessionID())
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()),
)
const selectLocal = () => {
setOnLocal(true)
// Request fresh branch info — cheap git rev-parse, ensures branch name
// is current even if the user switched branches in an external terminal
vscode.postMessage({ type: "agentManager.requestRepoInfo" })
const lid = localSessionID()
if (lid) {
session.selectSession(lid)
} else {
session.clearCurrentSession()
}
}
const navigate = (direction: "up" | "down") => {
const list = sorted()
if (list.length === 0) return
const current = session.currentSessionID()
const idx = current ? list.findIndex((s) => s.id === current) : -1
const next = direction === "up" ? idx - 1 : idx + 1
if (next < 0 || next >= list.length) return
session.selectSession(list[next]!.id)
const ids = sorted().map((s) => s.id)
const current = isLocal() ? undefined : session.currentSessionID()
const result = resolveNavigation(direction, current, ids)
if (result.action === "local") selectLocal()
else if (result.action === "select") {
setOnLocal(false)
session.selectSession(result.id)
}
}
onMount(() => {
@@ -74,6 +109,18 @@ const AgentManagerContent: Component = () => {
}
window.addEventListener("message", handler)
// When a session is created while the user is on local with no local
// session yet, adopt it. The session context sets currentSessionID before
// this listener fires, so we also re-assert onLocal to keep the sidebar
// highlight on the local item rather than the SESSIONS list.
const unsubCreate = vscode.onMessage((msg) => {
if (msg.type === "sessionCreated" && onLocal() && !localSessionID()) {
const created = msg as { type: string; session: { id: string } }
setLocalSessionID(created.session.id)
setOnLocal(true)
}
})
// Worktree metadata and setup progress messages
const unsub = vscode.onMessage((msg) => {
if (msg.type === "agentManager.sessionMeta") {
@@ -89,6 +136,11 @@ const AgentManagerContent: Component = () => {
}))
}
if (msg.type === "agentManager.repoInfo") {
const info = msg as AgentManagerRepoInfoMessage
setRepoBranch(info.branch)
}
if (msg.type === "agentManager.worktreeSetup") {
const ev = msg as AgentManagerWorktreeSetupMessage
if (ev.status === "ready" || ev.status === "error") {
@@ -103,24 +155,75 @@ const AgentManagerContent: Component = () => {
onCleanup(() => {
window.removeEventListener("message", handler)
unsubCreate()
unsub()
})
})
// Reset mode when session is cleared
// Persist local session ID to webview state for recovery
createEffect(() => {
const lid = localSessionID()
vscode.setState({ localSessionID: lid })
})
// Invalidate persisted local session ID if it no longer exists (e.g. server
// restarted, session expired). Without this the UI would get stuck selecting a
// ghost session on recovery.
createEffect(() => {
const all = session.sessions()
if (all.length === 0) return // sessions not loaded yet
const lid = localSessionID()
if (!lid) return
const valid = validateLocalSession(
lid,
all.map((s) => s.id),
)
if (!valid) {
setLocalSessionID(undefined)
session.clearCurrentSession()
}
})
// Reset worktree mode when no session is selected
createEffect(() => {
if (!session.currentSessionID()) worktreeMode.setMode("local")
})
// If we have a persisted local session, select it on mount
onMount(() => {
const lid = localSessionID()
if (lid) session.selectSession(lid)
})
const getMeta = (sessionId: string): WorktreeMeta | undefined => sessionMeta()[sessionId]
return (
<div class="am-layout">
<div class="am-sidebar">
<div class="am-sidebar-header">AGENT MANAGER</div>
<Button variant="primary" size="large" onClick={() => session.clearCurrentSession()}>
<Button
variant="primary"
size="large"
onClick={() => {
setOnLocal(false)
session.clearCurrentSession()
}}
>
+ New Agent
</Button>
<button class={`am-local-item ${isLocal() ? "am-local-item-active" : ""}`} onClick={() => selectLocal()}>
<svg class="am-local-icon" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<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" />
</svg>
<div class="am-local-text">
<span class="am-local-label">local</span>
<Show when={repoBranch()}>
<span class="am-local-branch">{repoBranch()}</span>
</Show>
</div>
</button>
<div class="am-sessions-header">SESSIONS</div>
<div class="am-list">
<For each={sorted()}>
@@ -128,8 +231,11 @@ const AgentManagerContent: Component = () => {
const meta = () => getMeta(s.id)
return (
<button
class={`am-item ${s.id === session.currentSessionID() ? "am-item-active" : ""}`}
onClick={() => session.selectSession(s.id)}
class={`am-item ${!isLocal() && s.id === session.currentSessionID() ? "am-item-active" : ""}`}
onClick={() => {
setOnLocal(false)
session.selectSession(s.id)
}}
>
<span class="am-item-title">
{s.title || "Untitled"}
@@ -165,7 +271,12 @@ const AgentManagerContent: Component = () => {
</div>
</div>
</Show>
<ChatView onSelectSession={(id) => session.selectSession(id)} />
<ChatView
onSelectSession={(id) => {
setOnLocal(false)
session.selectSession(id)
}}
/>
</div>
</div>
)
@@ -32,8 +32,65 @@
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-weak, var(--vscode-descriptionForeground, #888));
padding: 4px 8px 0;
color: var(--text-weaker, var(--vscode-descriptionForeground, #666));
padding: 8px 10px 2px;
}
/* Fixed local workspace item — styled like a session row */
.am-local-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 6px;
cursor: pointer;
background: none;
border: none;
width: 100%;
text-align: left;
color: var(--text-base, var(--vscode-foreground));
font-size: 13px;
font-family: inherit;
flex-shrink: 0;
transition: background-color 0.15s ease;
}
.am-local-item:hover {
background: var(--surface-inset-base-hover, rgba(255, 255, 255, 0.05));
}
.am-local-item-active {
background: var(--surface-base-active, rgba(255, 255, 255, 0.08));
}
.am-local-icon {
width: 18px;
height: 18px;
flex-shrink: 0;
color: var(--text-weaker, var(--vscode-descriptionForeground, #666));
}
.am-local-text {
display: flex;
flex-direction: column;
min-width: 0;
gap: 0;
}
.am-local-label {
font-size: 13px;
font-weight: 500;
line-height: 1.4;
}
.am-local-branch {
font-size: 11px;
color: var(--text-weaker, var(--vscode-descriptionForeground, #777));
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Session list */
@@ -52,8 +109,8 @@
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 8px;
border-radius: 4px;
padding: 6px 10px;
border-radius: 6px;
cursor: pointer;
background: none;
border: none;
@@ -62,15 +119,16 @@
color: var(--text-base, var(--vscode-foreground));
font-size: 13px;
font-family: inherit;
transition: background-color 0.15s ease;
}
.am-item:hover {
background: var(--surface-inset-base-hover, var(--vscode-list-hoverBackground, #2a2d2e));
background: var(--surface-inset-base-hover, rgba(255, 255, 255, 0.05));
}
.am-item-active {
background: var(--surface-interactive-base, var(--vscode-list-activeSelectionBackground, #04395e));
color: var(--text-on-interactive-base, var(--vscode-list-activeSelectionForeground, #fff));
background: var(--surface-base-active, rgba(255, 255, 255, 0.08));
color: var(--text-base, var(--vscode-foreground));
}
.am-item-title {
@@ -93,7 +151,7 @@
}
.am-item-active .am-item-time {
color: var(--text-on-interactive-weak, rgba(255, 255, 255, 0.7));
color: var(--text-weaker, var(--vscode-descriptionForeground, #888));
}
/* Worktree badge */
@@ -116,8 +174,8 @@
}
.am-item-active .am-worktree-badge {
background: rgba(255, 255, 255, 0.15);
color: var(--text-on-interactive-weak, rgba(255, 255, 255, 0.85));
background: var(--surface-inset-base, var(--vscode-badge-background, #4d4d4d));
color: var(--text-weak, var(--vscode-badge-foreground, #ccc));
}
/* Detail pane */
@@ -0,0 +1,44 @@
/**
* Pure navigation logic for the agent manager sidebar.
*
* The sidebar has a fixed "local" item at index -1, followed by
* session items at indices 0..N-1 (sorted newest-first).
*
* Returns the action to take: select a session by ID, go to local, or do nothing.
*/
export type NavResult = { action: "select"; id: string } | { action: "local" } | { action: "none" }
export function resolveNavigation(direction: "up" | "down", current: string | undefined, ids: string[]): NavResult {
// Determine current position: -1 = local, 0..N-1 = session index
if (!current) {
// On local
if (direction === "up") return { action: "none" }
if (ids.length === 0) return { action: "none" }
return { action: "select", id: ids[0]! }
}
const idx = ids.indexOf(current)
// Current session not found in list — don't navigate
if (idx === -1) return { action: "none" }
const next = direction === "up" ? idx - 1 : idx + 1
// Moving up past the first session → go to local
if (next === -1) return { action: "local" }
// At the bottom boundary
if (next >= ids.length) return { action: "none" }
return { action: "select", id: ids[next]! }
}
/**
* Validate a persisted local session ID against the current sessions list.
* Returns the ID if it still exists, undefined otherwise.
*/
export function validateLocalSession(persisted: string | undefined, ids: string[]): string | undefined {
if (!persisted) return undefined
if (ids.indexOf(persisted) === -1) return undefined
return persisted
}
@@ -511,6 +511,12 @@ export interface AgentManagerSessionMetaMessage {
parentBranch?: string
}
// Agent Manager repo info (current branch of the main workspace)
export interface AgentManagerRepoInfoMessage {
type: "agentManager.repoInfo"
branch: string
}
// Agent Manager worktree setup progress
export interface AgentManagerWorktreeSetupMessage {
type: "agentManager.worktreeSetup"
@@ -554,6 +560,7 @@ export type ExtensionMessage =
| ConfigUpdatedMessage
| NotificationSettingsLoadedMessage
| AgentManagerSessionMetaMessage
| AgentManagerRepoInfoMessage
| AgentManagerWorktreeSetupMessage
// ============================================
@@ -750,6 +757,10 @@ export interface TelemetryRequest {
properties?: Record<string, unknown>
}
export interface RequestRepoInfoMessage {
type: "agentManager.requestRepoInfo"
}
export type WebviewMessage =
| SendMessageRequest
| AbortRequest
@@ -787,6 +798,7 @@ export type WebviewMessage =
| SyncSessionRequest
| CreateWorktreeSessionRequest
| TelemetryRequest
| RequestRepoInfoMessage
// ============================================
// VS Code API type
+5 -5
View File
@@ -76,13 +76,15 @@
"@gitlab/opencode-gitlab-auth": "1.3.2",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
"@kilocode/kilo-gateway": "workspace:*",
"@kilocode/kilo-telemetry": "workspace:*",
"@kilocode/plugin": "workspace:*",
"@kilocode/sdk": "workspace:*",
"@modelcontextprotocol/sdk": "1.25.2",
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
"@kilocode/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@kilocode/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.4",
"@opentui/core": "0.1.79",
@@ -122,9 +124,7 @@
"xdg-basedir": "5.1.0",
"yargs": "18.0.0",
"zod": "catalog:",
"zod-to-json-schema": "3.24.5",
"@kilocode/kilo-gateway": "workspace:*",
"@kilocode/kilo-telemetry": "workspace:*"
"zod-to-json-schema": "3.24.5"
},
"peerDependencies": {}
}