Merge pull request #13407 from Kilo-Org/plan-agent-manager-sessions

feat(agent-manager): replace sessions list with a per-project history button
This commit is contained in:
Marius
2026-08-25 14:05:05 +02:00
committed by GitHub
29 changed files with 350 additions and 608 deletions
@@ -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.
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:00088eaafbe9b65c5baf0eb1ce185a95da3e8492bf3c77a940f20d329ff93046
size 39409
oid sha256:490320ae56acbbc5ea7283d72fb8dd4b93a28ce3f1ec9678ce8ff3ea1bab953e
size 34649
+4
View File
@@ -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")
})
})
+42 -206
View File
@@ -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}
/>
</>
)
}
@@ -3,8 +3,6 @@ import { For, type Component } from "solid-js"
/** Staggered widths so stacked placeholder rows read as a list, not a striped block. */
const BRANCH = ["62%", "44%", "70%", "52%"]
const SUB = ["38%", "30%", "46%", "34%"]
const TITLE = ["70%", "55%", "65%", "48%"]
const rows = (count: number) => Array.from({ length: count }, (_, index) => index)
/** Offset each row's pulse so any row count keeps the wave effect. */
@@ -33,23 +31,6 @@ export const WorktreeSkeleton: Component<{ count?: number }> = (props) => (
</div>
)
/** Placeholder session rows shown until a project's session list arrives. */
export const SessionSkeleton: Component<{ count?: number }> = (props) => (
<div class="am-skeleton-list">
<For each={rows(props.count ?? 3)}>
{(index) => (
<div class="am-skeleton-session">
<div
class="am-skeleton-session-title"
style={{ width: TITLE[index % TITLE.length], "animation-delay": delay(index) }}
/>
<div class="am-skeleton-session-time" style={{ "animation-delay": delay(index) }} />
</div>
)}
</For>
</div>
)
/** Placeholder for the two-line git stats column on a local or worktree row. */
export const StatsSkeleton: Component = () => (
<div class="am-worktree-stats-skeleton">
@@ -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,31 +3586,6 @@ body.am-wt-dragging-active * {
animation: am-skeleton-pulse 1.5s ease-in-out infinite;
}
/* Session skeleton — matches .am-item layout */
.am-skeleton-session {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 10px;
}
.am-skeleton-session-title {
height: 13px;
border-radius: 3px;
background: var(--text-base);
animation: am-skeleton-pulse 1.5s ease-in-out infinite;
}
.am-skeleton-session-time {
height: 10px;
width: 52px;
border-radius: 3px;
background: var(--text-base);
animation: am-skeleton-pulse 1.5s ease-in-out infinite;
flex-shrink: 0;
}
/* Tab switcher for New/Import tabs in the dialog */
.am-tab-switcher {
@@ -4624,14 +4519,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 +4701,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"
@@ -1596,6 +1603,7 @@ export type WebviewMessage =
| RemoveStaleWorktreeRequest
| PromoteSessionRequest
| OpenLocallyRequest
| OpenSessionLocallyRequest
| AddSessionToWorktreeRequest
| ForkSessionRequest
| SidebarForkSessionRequest