Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/sidebar-tabs-pin-close-to-right.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---

Add pinning and Close to the Right to the sidebar session tabs. Pinned tabs stay at the front and survive Close Others and Close to the Right.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 14 additions & 0 deletions packages/kilo-vscode/tests/unit/local-tabs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,20 @@ describe("local session tabs", () => {
const current = state(["s1", "s2"], "s1")
expect(closeOtherTabs(current, "missing")).toBe(current)
})

it("keeps pinned tabs open when closing the others", () => {
expect(closeOtherTabs(state(["s1", "s2", "s3"], "s1"), "s1", ["s3"])).toEqual({
ids: ["s1", "s3"],
active: "s1",
})
})

it("activates the retained tab even when pinned neighbors survive", () => {
expect(closeOtherTabs(state(["s1", "s2", "s3"], "s2"), "s3", ["s1"])).toEqual({
ids: ["s1", "s3"],
active: "s3",
})
})
})

describe("shared close selection", () => {
Expand Down
121 changes: 121 additions & 0 deletions packages/kilo-vscode/tests/unit/session-close.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { describe, expect, it } from "bun:test"
import { closableRight, closeToRight, sessionCloseDeps } from "../../webview-ui/src/utils/session-close"

const PENDING = "sidebar-pending:1"

/**
* Fake session-only tab bar for the sidebar: no terminals or review tab, and a
* close picks a neighbor only when the active tab itself closes.
*/
function scene(ids: string[], opts: { active?: string; pins?: string[] } = {}) {
const open = [...ids]
const calls: string[] = []
const pins = new Set(opts.pins ?? [])
let active = opts.active
const remove = (id: string) => {
const index = open.indexOf(id)
if (index >= 0) open.splice(index, 1)
}
const close = (id: string) => {
calls.push(`close:${id}`)
const wasActive = active === id
const index = open.indexOf(id)
remove(id)
if (!wasActive) return
active = open[Math.min(index, open.length - 1)]
}
const reveal = (id: string) => {
calls.push(`reveal:${id}`)
active = id
}
const deps = sessionCloseDeps({
ids: () => [...open],
visible: () => active,
isPinned: (id) => pins.has(id),
close,
reveal,
})
return { deps, calls, open, visible: () => active }
}

describe("sidebar closable right", () => {
it("returns every tab after the target in order", () => {
const s = scene(["a", "b", "c", "d"])
expect(closableRight("b", s.deps)).toEqual(["c", "d"])
})

it("returns nothing for the last tab or an unknown target", () => {
const s = scene(["a", "b"])
expect(closableRight("b", s.deps)).toEqual([])
expect(closableRight("x", s.deps)).toEqual([])
})

it("skips pinned tabs to the right", () => {
const s = scene(["a", PENDING, "c"], { pins: ["c"] })
expect(closableRight("a", s.deps)).toEqual([PENDING])
})

it("returns nothing when only pinned tabs are to the right", () => {
const s = scene(["a", "b"], { pins: ["b"] })
expect(closableRight("a", s.deps)).toEqual([])
})
})

describe("sidebar close to right", () => {
it("closes only the trailing tabs and leaves the visible tab alone", () => {
const s = scene(["a", "b", "c", "d"], { active: "a" })

closeToRight("b", s.deps)

expect(s.calls).toEqual(["close:c", "close:d"])
expect(s.open).toEqual(["a", "b"])
expect(s.visible()).toBe("a")
})

it("keeps pinned tabs to the right open", () => {
const s = scene(["a", "b", "c", "d"], { active: "a", pins: ["c"] })

closeToRight("b", s.deps)

expect(s.calls).toEqual(["close:d"])
expect(s.open).toEqual(["a", "b", "c"])
expect(s.visible()).toBe("a")
})

it("moves selection to the target only when the visible tab closes", () => {
const s = scene(["a", "b", "c"], { active: "c" })

closeToRight("b", s.deps)

expect(s.calls).toEqual(["reveal:b", "close:c"])
expect(s.open).toEqual(["a", "b"])
expect(s.visible()).toBe("b")
})

it("does nothing when the target is the last tab", () => {
const s = scene(["a", "b"], { active: "a" })

closeToRight("b", s.deps)

expect(s.calls).toEqual([])
expect(s.open).toEqual(["a", "b"])
})

it("does nothing when only pinned tabs are to the right", () => {
const s = scene(["a", "b"], { active: "a", pins: ["b"] })

closeToRight("a", s.deps)

expect(s.calls).toEqual([])
expect(s.open).toEqual(["a", "b"])
})

it("closes a pending draft to the right", () => {
const s = scene(["a", PENDING, "b"], { active: "a" })

closeToRight("a", s.deps)

expect(s.calls).toEqual([`close:${PENDING}`, "close:b"])
expect(s.open).toEqual(["a"])
})
})
12 changes: 7 additions & 5 deletions packages/kilo-vscode/tests/unit/sidebar-tab-dnd.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ describe("sidebar tab drag ordering", () => {
expect(strip).toContain("<DragDropProvider")
expect(strip).toContain("<DragDropSensors />")
expect(strip).toContain("<ConstrainDragYAxis />")
expect(strip).toContain("<SortableProvider ids={tabs.ids()}>")
expect(strip).toContain("<SortableProvider ids={tabs.display()}>")
expect(strip).toContain("<SortableTabContainer id={id}>")
})

Expand All @@ -22,18 +22,20 @@ describe("sidebar tab drag ordering", () => {

it("supports keyboard reorder without replacing selection navigation", () => {
expect(strip).toContain('tabs.move(id, event.key === "ArrowLeft" ? -1 : 1)')
expect(strip).toContain("handleTabKey({ ids: tabs.ids(), id, event, select: tabs.select, root })")
expect(strip).toContain("handleTabKey({ ids: tabs.display(), id, event, select: tabs.select, root })")
expect(strip).toContain('aria-live="polite"')
})

it("persists real order and active tab through VS Code webview state", () => {
it("persists real order, active tab, and pins through VS Code webview state", () => {
expect(tabs).toContain("sidebarSessionTabIDs: tabs")
expect(tabs).toContain("sidebarActiveSessionTabID: selected")
expect(tabs).toContain("sidebarPinnedSessionTabIDs: pins")
expect(tabs).toContain("timer = setTimeout(persist, 300)")
})

it("releases frozen widths after closing and after dragging", () => {
expect(strip.match(/requestAnimationFrame\(release\)/g)).toHaveLength(2)
it("releases frozen widths after closing, close to right, and dragging", () => {
expect(strip.match(/requestAnimationFrame\(release\)/g)).toHaveLength(3)
expect(strip).toMatch(/const closeRight = \(id: string\) => \{[\s\S]*requestAnimationFrame\(release\)/)
expect(strip).toMatch(/const dragEnd = \(\) => \{[\s\S]*release\(\)/)
})
})
32 changes: 32 additions & 0 deletions packages/kilo-vscode/tests/unit/tab-order.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
replaceInTabOrder,
insertInTabOrderAfter,
} from "../../webview-ui/agent-manager/tab-order"
import { reorderPinnedTabs } from "../../webview-ui/src/utils/tab-order"
import { moveTab } from "../../webview-ui/src/utils/tab-order"

describe("reorderTabs", () => {
Expand Down Expand Up @@ -183,6 +184,37 @@ describe("togglePinnedTab", () => {
})
})

describe("reorderPinnedTabs", () => {
const ids = ["a", "b", "c", "d"]

it("reorders inside the pinned group without touching the stored order", () => {
expect(reorderPinnedTabs(ids, ["c", "d"], "d", "c")).toEqual({ ids, pinned: ["d", "c"] })
})

it("reorders unpinned tabs without touching the pin order", () => {
expect(reorderPinnedTabs(ids, ["d"], "b", "a")).toEqual({ ids: ["b", "a", "c", "d"], pinned: ["d"] })
})

it("rejects a move that crosses the pinned boundary", () => {
expect(reorderPinnedTabs(ids, ["d"], "a", "d")).toBeUndefined()
expect(reorderPinnedTabs(ids, ["d"], "d", "a")).toBeUndefined()
})

it("rejects a repeated or missing id", () => {
expect(reorderPinnedTabs(ids, [], "a", "a")).toBeUndefined()
expect(reorderPinnedTabs(ids, ["x"], "x", "a")).toBeUndefined()
expect(reorderPinnedTabs(ids, [], "missing", "a")).toBeUndefined()
})

it("does not mutate the inputs", () => {
const order = ["a", "b"]
const pins = ["b"]
expect(reorderPinnedTabs(order, pins, "b", "b")).toBeUndefined()
expect(order).toEqual(["a", "b"])
expect(pins).toEqual(["b"])
})
})

describe("firstOrderedTitle", () => {
const items = [{ id: "a", title: "Alpha" }, { id: "b", title: "Beta" }, { id: "c", title: "" }, { id: "d" }]

Expand Down
30 changes: 1 addition & 29 deletions packages/kilo-vscode/webview-ui/agent-manager/tab-order.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* Pure tab-ordering logic for the agent manager.
*/

export { reorderTabs } from "../src/utils/tab-order"
export { applyPinnedTabs, reorderTabs, togglePinnedTab } from "../src/utils/tab-order"

/**
* Apply a custom ordering to a list of items.
Expand All @@ -26,34 +26,6 @@ export function applyTabOrder<T extends { id: string }>(items: T[], order: strin
return ordered
}

/**
* Move pinned items to the front, keeping their pin order.
*
* Unpinned items keep the sequence they already had, so this layers on top of
* `applyTabOrder` without discarding a user's drag order. Pinned ids that are
* not currently open are ignored.
*/
export function applyPinnedTabs<T extends { id: string }>(items: T[], pinned: string[] | undefined): T[] {
if (!pinned || pinned.length === 0) return items
const rank = new Map(pinned.map((id, i) => [id, i]))
const head = items.filter((item) => rank.has(item.id)).sort((a, b) => rank.get(a.id)! - rank.get(b.id)!)
if (head.length === 0) return items
const tail = items.filter((item) => !rank.has(item.id))
return [...head, ...tail]
}

/**
* Pin or unpin `id`.
*
* A newly pinned tab lands at the end of the pinned group so existing pins keep
* their position. Unpinning drops the tab back into the unpinned group.
*/
export function togglePinnedTab(pinned: string[] | undefined, id: string): string[] {
const base = pinned ?? []
if (base.includes(id)) return base.filter((item) => item !== id)
return [...base, id]
}

/**
* Replace `oldId` with `newId` in `order`, preserving its position.
* Returns a new array, or undefined if `oldId` isn't in `order`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,19 +46,19 @@ export const SessionTabStrip: Component = () => {
(event.key === "ArrowLeft" || event.key === "ArrowRight")
) {
event.preventDefault()
const ids = tabs.ids()
const ids = tabs.display()
const target = tabs.move(id, event.key === "ArrowLeft" ? -1 : 1)
if (target === undefined) return
tabs.persist()
setAnnouncement(`${title(id)} ${target + 1}/${ids.length}`)
focusTabElement(root, id)
return
}
handleTabKey({ ids: tabs.ids(), id, event, select: tabs.select, root })
handleTabKey({ ids: tabs.display(), id, event, select: tabs.select, root })
}
const scroll = useTabScroll(tabs.ids, tabs.active)
const scroll = useTabScroll(tabs.display, tabs.active)
const rows = createMemo(() =>
tabs.ids().map((id) => ({
tabs.display().map((id) => ({
id,
title: title(id),
active: tabs.active() === id,
Expand All @@ -82,6 +82,13 @@ export const SessionTabStrip: Component = () => {
focusTabElement(document, id, focusPrompt)
requestAnimationFrame(release)
}
const closeRight = (id: string) => {
freeze()
tabs.closeToRight(id)
if (tabs.active() === id) focusTabElement(document, id, focusPrompt)
else focusSelectedTab(document, focusPrompt)
requestAnimationFrame(release)
}
const dragStart = (event: DragEvent) => {
const id = event.draggable?.id
if (typeof id !== "string") return
Expand Down Expand Up @@ -129,10 +136,10 @@ export const SessionTabStrip: Component = () => {
class="am-tab-list"
ref={scroll.setRef}
role="tablist"
style={{ "--tab-count": `${tabs.ids().length}` } as JSX.CSSProperties}
style={{ "--tab-count": `${tabs.display().length}` } as JSX.CSSProperties}
>
<SortableProvider ids={tabs.ids()}>
<For each={tabs.ids()}>
<SortableProvider ids={tabs.display()}>
<For each={tabs.display()}>
{(id) => (
<SortableTabContainer id={id}>
<SessionTabMenu
Expand All @@ -143,11 +150,16 @@ export const SessionTabStrip: Component = () => {
: undefined
}
onClose={() => close(id)}
onCloseOthers={tabs.ids().length > 1 ? () => closeOthers(id) : undefined}
onCloseOthers={tabs.display().length > 1 ? () => closeOthers(id) : undefined}
onCloseToRight={tabs.closableRight(id).length ? () => closeRight(id) : undefined}
pinned={tabs.isPinned(id)}
onTogglePin={isPendingTab(id) ? undefined : () => tabs.togglePinned(id)}
>
<SessionTab
title={title(id)}
active={tabs.active() === id}
pinned={tabs.isPinned(id)}
pinnedLabel={language.t("agentManager.tab.pinned")}
state={state(id)}
stateLabel={language.t(label(state(id)))}
closeTitle={language.t("common.closeTab")}
Expand Down
Loading
Loading