Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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/session-tab-switcher.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---

Add a searchable open-tabs switcher to the sidebar tab bar.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
31 changes: 31 additions & 0 deletions packages/kilo-vscode/tests/accessibility.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const STORIES = [
{ id: "settings--providers-configure", name: "Settings / providers empty state" },
{ id: "marketplace--empty-list", name: "Marketplace / empty state" },
{ id: "agentmanager--sidebar-search-open", name: "Agent Manager / sidebar search" },
{ id: "session-tabs--switcher-open", name: "Session tabs / switcher" },
]

function url(id: string) {
Expand Down Expand Up @@ -178,4 +179,34 @@ test.describe("webview accessibility ratchet", () => {
).toBeVisible()
await expect(page.getByText("⌘F", { exact: true })).toBeVisible()
})

test("Session tab switcher restores chat focus after keyboard and mouse selection", async ({ page }) => {
await open(page, "session-tabs--switcher-open")

const input = page.getByPlaceholder("Search open tabs")
const prompt = page.getByRole("textbox", { name: "Chat input" })
await expect(page.locator('[data-slot="list-item"][data-active="true"]')).toHaveCount(0)
await expect(page.locator('[data-slot="list-item"][data-key="current"]')).toHaveAttribute("data-selected", "true")
await expect(page.locator('[data-slot="list-item"][data-key="refactor"]')).toHaveAttribute("data-selected", "false")
await input.press("ArrowDown")
await input.press("Enter")
await expect(prompt).toBeFocused()

await page.getByRole("button", { name: "Show open tabs" }).click()
await page.locator('[data-slot="list-item"][data-key="current"]').click()
await expect(prompt).toBeFocused()

// Enter without prior ArrowDown selects the first filtered result (noInitialSelection)
await page.getByRole("button", { name: "Show open tabs" }).click()
await input.fill("Review")
await input.press("Enter")
await expect(prompt).toBeFocused()
})

test("Search popovers expose accessible dialog names", async ({ page }) => {
for (const id of ["agentmanager--sidebar-search-open", "session-tabs--switcher-open"]) {
await open(page, id)
await expect(page.getByRole("dialog")).toHaveAccessibleName(/.+/)
}
})
})
194 changes: 194 additions & 0 deletions packages/kilo-vscode/tests/fixtures/session-tab-switcher.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import assert from "node:assert/strict"
import { Window } from "happy-dom"

const window = new Window({ url: "http://localhost" })
const style = window.getComputedStyle.bind(window)
Object.assign(globalThis, {
window,
document: window.document,
navigator: window.navigator,
Node: window.Node,
Element: window.Element,
HTMLElement: window.HTMLElement,
HTMLInputElement: window.HTMLInputElement,
HTMLTextAreaElement: window.HTMLTextAreaElement,
SVGElement: window.SVGElement,
MutationObserver: window.MutationObserver,
ResizeObserver: window.ResizeObserver,
CustomEvent: window.CustomEvent,
Event: window.Event,
FocusEvent: window.FocusEvent,
InputEvent: window.InputEvent,
KeyboardEvent: window.KeyboardEvent,
MouseEvent: window.MouseEvent,
PointerEvent: window.PointerEvent,
getComputedStyle: (node: Element) => {
const value = style(node)
Object.defineProperty(value, "animationName", { configurable: true, value: "none" })
return value
},
requestAnimationFrame: window.requestAnimationFrame.bind(window),
cancelAnimationFrame: window.cancelAnimationFrame.bind(window),
})

const { Show, createSignal } = await import("solid-js")
const { render } = await import("solid-js/web")
const { SessionTabSwitcher } = await import("../../webview-ui/src/components/chat/SessionTabSwitcher")

const rows = [
{ id: "alpha", title: "Alpha", active: true, busy: false, pending: false },
{ id: "beta", title: "Beta", active: false, busy: true, pending: false },
{ id: "gamma", title: "Gamma", active: false, busy: false, pending: false },
]
const [items, setItems] = createSignal(rows)
const selected: string[] = []
const restored: boolean[] = []
const closed: string[] = []
const target = document.createElement("textarea")
const root = document.createElement("div")
document.body.append(root, target)

const dispose = render(
() => (
<Show when={items().length > 1}>
<SessionTabSwitcher
items={items}
labels={{
open: "Show open tabs",
search: "Search open tabs",
close: "Close tab",
current: "Current",
pending: "New",
busy: "Working",
}}
onSelect={(id) => selected.push(id)}
onRestore={() => {
restored.push(true)
target.focus()
}}
onClose={(id) => {
closed.push(id)
setItems((value) => value.filter((item) => item.id !== id))
}}
portal={false}
/>
</Show>
),
root,
)

function query<T extends Element>(selector: string, message: string) {
const node = root.querySelector<T>(selector)
assert(node, message)
return node
}

const settle = async () => {
await Promise.resolve()
await window.happyDOM.waitUntilComplete()
}

const open = async () => {
query<HTMLButtonElement>('[aria-label="Show open tabs"]', "Switcher trigger did not render").click()
await settle()
assert.equal(root.querySelector('[data-slot="list-item"][data-active="true"]'), null, "First tab was highlighted")
assert.equal(
query('[data-slot="list-item"][data-key="alpha"]', "Current tab did not render").getAttribute("data-selected"),
"true",
"Current tab was not selected",
)
}

async function closeFiltered() {
await open()

const input = query<HTMLInputElement>('[data-slot="list-search"] input', "Switcher search did not render")
input.value = "be"
input.dispatchEvent(new InputEvent("input", { bubbles: true, data: "be", inputType: "insertText" }))
await settle()

const close = query<HTMLButtonElement>(
'[aria-label="Close tab: Beta"]',
"Filtered result close button did not render",
)
assert.equal(close.tabIndex, 0, "Close button is not keyboard reachable")
close.click()
await settle()

assert.deepEqual(closed, ["beta"], "Unexpected closed tabs")
assert.equal(input.value, "be", "Closing a result cleared the filter")
assert.equal(document.activeElement, input, "Search input was not refocused after closing a result")
}

async function selectFiltered() {
setItems(rows)
await settle()

query<HTMLButtonElement>('[data-slot="list-item"][data-key="beta"]', "Filtered result did not return").click()
await settle()

assert.deepEqual(selected, ["beta"], "Unexpected selected tabs")
assert.deepEqual(restored, [true], "Prompt focus was not restored")
assert.equal(document.activeElement, target, "Popover close stole focus from the prompt")
}

async function enterSelectsFirst() {
setItems(rows)
selected.length = 0
restored.length = 0
await settle()

await open()

const input = query<HTMLInputElement>('[data-slot="list-search"] input', "Switcher search did not render")
input.value = "ga"
input.dispatchEvent(new InputEvent("input", { bubbles: true, data: "ga", inputType: "insertText" }))
await settle()

input.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }))
await settle()

assert.deepEqual(selected, ["gamma"], "Enter did not select the first filtered result")
assert.deepEqual(restored, [true], "Prompt focus was not restored after Enter")
assert.equal(document.activeElement, target, "Popover close stole focus from the prompt")
}

async function deleteReopened() {
await open()

const alpha = query<HTMLButtonElement>(
'[data-slot="list-item"][data-key="alpha"]',
"Switcher did not reset its filter when reopened",
)
alpha.focus()
alpha.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Delete" }))
await settle()

assert.deepEqual(closed, ["beta", "alpha"], "Keyboard close failed")
}

async function closeToOne() {
closed.length = 0
restored.length = 0

const beta = query<HTMLButtonElement>(
'[data-slot="list-item"][data-key="beta"]',
"Switcher did not retain the remaining tabs",
)
beta.focus()
beta.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Delete" }))
await settle()

assert.deepEqual(closed, ["beta"], "Final visible close failed")
assert.deepEqual(restored, [true], "Prompt did not receive the focus handoff")
assert.equal(root.querySelector('[aria-label="Show open tabs"]'), null, "Switcher did not unmount")
assert.equal(document.activeElement, target, "Prompt was not focused after the switcher unmounted")
}

await closeFiltered()
await selectFiltered()
await enterSelectsFirst()
await deleteReopened()
await closeToOne()

dispose()
57 changes: 57 additions & 0 deletions packages/kilo-vscode/tests/unit/session-tab-switcher.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, expect, it } from "bun:test"
import { unlinkSync } from "node:fs"
import path from "node:path"
import { build } from "esbuild"
import { solidPlugin } from "esbuild-plugin-solid"

const ROOT = path.resolve(import.meta.dir, "../..")
const WEBVIEW = path.join(ROOT, "webview-ui")
const FIXTURE = path.join(ROOT, "tests/fixtures/session-tab-switcher.tsx")

describe("SessionTabSwitcher", () => {
it("preserves filtering and restores focus across tab actions", async () => {
const solid = path.dirname(Bun.resolveSync("solid-js/package.json", WEBVIEW))
const aliases: Record<string, string> = {
"solid-js": path.join(solid, "dist/solid.js"),
"solid-js/web": path.join(solid, "web/dist/web.js"),
"solid-js/store": path.join(solid, "store/dist/store.js"),
}
const dedupe = {
name: "solid-dedupe",
setup(ctx: Parameters<NonNullable<Parameters<typeof build>[0]["plugins"]>[number]["setup"]>[0]) {
ctx.onResolve({ filter: /^solid-js(\/web|\/store)?$/ }, (args) => ({ path: aliases[args.path] }))
},
}
const result = await build({
entryPoints: [FIXTURE],
bundle: true,
conditions: ["browser"],
external: ["happy-dom"],
format: "esm",
logLevel: "silent",
platform: "node",
plugins: [dedupe, solidPlugin()],
target: "es2022",
write: false,
})
const file = path.join(ROOT, `.session-tab-switcher-${crypto.randomUUID()}.mjs`)
await Bun.write(file, result.outputFiles[0]!.contents)
const child = Bun.spawnSync(["bun", file], { cwd: WEBVIEW, stdout: "pipe", stderr: "pipe" })
unlinkSync(file)

const output = child.stdout.toString() + child.stderr.toString()
expect(child.exitCode, output).toBe(0)
})

it("uses logical properties for RTL layout", async () => {
const css = await Bun.file(path.join(WEBVIEW, "src/styles/session-tabs.css")).text()
const start = css.indexOf(".session-tab-switcher-wrap")
const end = css.indexOf("/* Match tab context menus", start)
const switcher = css.slice(start, end)

expect(switcher).toContain("border-inline-start")
expect(switcher).toContain("inset-inline-end")
expect(switcher).toContain("padding-inline")
expect(switcher).not.toMatch(/\b(?:left|right|margin-left|margin-right|border-left|border-right)\s*:/)
})
})
36 changes: 21 additions & 15 deletions packages/kilo-vscode/webview-ui/agent-manager/SidebarSearchMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { Show, createEffect, createSignal } from "solid-js"
import type { Accessor, Component } from "solid-js"
import { Icon } from "@kilocode/kilo-ui/icon"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { List } from "@kilocode/kilo-ui/list"
import type { ListRef } from "@kilocode/kilo-ui/list"
import { Popover } from "@kilocode/kilo-ui/popover"
Expand Down Expand Up @@ -71,16 +72,19 @@ export const SidebarSearchMenu: Component<SidebarSearchMenuProps> = (props) => {
onOpenChange={close}
modal={false}
portal={props.portal}
class="am-sidebar-search-popover"
triggerAs="button"
class="search-menu-popover am-sidebar-search-popover"
contentLabel={props.labels.search}
triggerAs={IconButton}
triggerProps={{
type: "button",
class: "am-sidebar-search-trigger",
icon: "magnifying-glass",
size: "normal",
variant: "ghost",
class: "search-menu-trigger",
"aria-label": props.labels.search,
}}
trigger={<Icon name="magnifying-glass" size="small" />}
>
<div ref={root} class="am-sidebar-search" data-agent-manager-native-text-shortcuts>
<div ref={root} class="search-menu am-sidebar-search" data-agent-manager-native-text-shortcuts>
<List<SidebarSearchItem>
ref={(value) => {
list = value
Expand All @@ -103,15 +107,15 @@ export const SidebarSearchMenu: Component<SidebarSearchMenuProps> = (props) => {
const working = item.state === "busy" || item.state === "retry"
return (
<span
class="am-sidebar-search-result"
class="search-menu-row"
data-slot="sidebar-search-result"
data-kind={item.kind}
data-state={item.state}
data-session-id={item.kind === "session" ? item.sessionId : undefined}
data-worktree-id={item.kind === "worktree" ? item.worktreeId : undefined}
>
<span class="am-sidebar-search-icon">
<Show when={!working} fallback={<Spinner class="am-sidebar-search-spinner" />}>
<span class="search-menu-icon">
<Show when={!working} fallback={<Spinner class="search-menu-spinner" />}>
<Show
when={item.kind !== "local"}
fallback={
Expand All @@ -125,9 +129,9 @@ export const SidebarSearchMenu: Component<SidebarSearchMenuProps> = (props) => {
</Show>
</Show>
</span>
<span class="am-sidebar-search-copy">
<span class="am-sidebar-search-title">{item.title}</span>
<span class="am-sidebar-search-meta">
<span class="search-menu-copy">
<span class="search-menu-title">{item.title}</span>
<span class="search-menu-meta am-sidebar-search-meta">
<Show when={item.section}>
{(section) => (
<span
Expand All @@ -140,16 +144,18 @@ export const SidebarSearchMenu: Component<SidebarSearchMenuProps> = (props) => {
</span>
</span>
<Show when={item.state === "waiting"}>
<span class="am-sidebar-search-status">{props.labels.waiting}</span>
<span class="search-menu-status am-sidebar-search-status">{props.labels.waiting}</span>
</Show>
<Show when={item.state === "retry"}>
<span class="am-sidebar-search-status">{props.labels.retry}</span>
<span class="search-menu-status am-sidebar-search-status">{props.labels.retry}</span>
</Show>
<Show when={item.kind !== "session" && item.state === "idle"}>
<span class="am-sidebar-search-count">{item.kind !== "session" ? item.count : ""}</span>
<span class="search-menu-status am-sidebar-search-count">
{item.kind !== "session" ? item.count : ""}
</span>
</Show>
<Show when={item.kind === "session" && item.state === "idle"}>
<span class="am-sidebar-search-time">{formatRelativeDate(item.updatedAt)}</span>
<span class="search-menu-status">{formatRelativeDate(item.updatedAt)}</span>
</Show>
</span>
)
Expand Down
Loading
Loading