feat(vscode): add searchable session tab switcher (#12462)

* feat(vscode): add searchable session tab switcher

* Add screenshot

* fix(vscode): label search popover dialogs

* fix(vscode): select first result on Enter in session tab switcher

* fix(ui): wrap kilocode_change marker around Enter fallback block

---------

Co-authored-by: marius-kilocode <marius@kilocode.ai>
This commit is contained in:
hdcode.dev
2026-07-23 11:08:02 +02:00
committed by GitHub
parent 2d16c00dd9
commit 8eeaa546ae
35 changed files with 843 additions and 122 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Add a searchable open-tabs switcher to the sidebar tab bar.
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:273c76976df0e83fa404ae2af2d561f8c38458e3ef1b32f4f5b1990a5906a195
size 14239
@@ -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) {
@@ -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(/.+/)
}
})
})
@@ -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()
@@ -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*:/)
})
})
@@ -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"
@@ -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
@@ -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={
@@ -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
@@ -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>
)
@@ -1443,56 +1443,16 @@ body.am-wt-dragging-active * {
padding-right: 4px;
border-left: 1px solid var(--border-weak-base);
}
.am-sidebar-search-trigger {
display: inline-flex;
width: 24px;
height: 24px;
flex-shrink: 0;
align-items: center;
justify-content: center;
padding: 0;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-weak);
cursor: pointer;
}
.am-sidebar-search-trigger:hover,
.am-sidebar-search-trigger[data-expanded] {
background: var(--surface-inset-base-hover);
color: var(--text-base);
}
.am-sidebar-search-popover[data-component="popover-content"] {
width: min(360px, calc(100vw - 24px));
max-height: min(480px, calc(100vh - 64px));
padding: 0;
overflow: hidden;
}
.am-sidebar-search-popover [data-slot="popover-body"] {
padding: 0;
}
.am-sidebar-search [data-component="list"] {
max-height: min(440px, calc(100vh - 88px));
gap: 0;
padding: 8px 0 0;
}
.am-sidebar-search [data-slot="list-search-wrapper"] {
margin: 0 0 4px;
padding: 0 8px;
}
.am-sidebar-search [data-slot="list-search"] {
background: var(--surface-inset-base);
}
.am-sidebar-search [data-slot="list-scroll"] {
max-height: 360px;
gap: 2px;
padding: 0 4px;
}
@@ -1513,65 +1473,11 @@ body.am-wt-dragging-active * {
padding: 6px 8px;
}
.am-sidebar-search [data-slot="list-item"][data-active="true"],
.am-sidebar-search [data-slot="list-item"][data-selected="true"] {
background: color-mix(in srgb, var(--text-base) 10%, transparent);
}
.am-sidebar-search-result {
display: flex;
width: 100%;
min-width: 0;
align-items: center;
gap: 8px;
}
.am-sidebar-search-icon {
display: inline-flex;
width: 18px;
height: 18px;
flex-shrink: 0;
align-items: center;
justify-content: center;
color: var(--text-weak);
}
.am-sidebar-search-spinner {
width: 14px;
height: 14px;
}
.am-sidebar-search-copy {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 1px;
text-align: left;
}
.am-sidebar-search-title,
.am-sidebar-search-meta {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.am-sidebar-search-title {
color: var(--text-base);
font-size: var(--kilo-font-size-12);
font-weight: 500;
line-height: 1.35;
}
.am-sidebar-search-meta {
display: flex;
align-items: center;
gap: 5px;
color: var(--text-base);
font-size: var(--kilo-font-size-10);
font-weight: 400;
line-height: 1.35;
}
.am-sidebar-search-swatch {
@@ -1581,16 +1487,6 @@ body.am-wt-dragging-active * {
border-radius: 50%;
}
.am-sidebar-search-status,
.am-sidebar-search-count,
.am-sidebar-search-time {
flex-shrink: 0;
margin-left: auto;
color: var(--text-base);
font-size: var(--kilo-font-size-10);
font-weight: 400;
}
.am-sidebar-search-status {
color: #fbbf24;
text-transform: uppercase;
@@ -11,6 +11,7 @@ import { setTabWidths } from "../../utils/tab-widths"
import { useVSCode } from "../../context/vscode"
import { SessionTab } from "./SessionTab"
import { SessionTabMenu } from "./SessionTabMenu"
import { SessionTabSwitcher } from "./SessionTabSwitcher"
import { ConstrainDragYAxis, SortableTabContainer } from "./TabDnd"
export const SessionTabStrip: Component = () => {
@@ -56,7 +57,15 @@ export const SessionTabStrip: Component = () => {
handleTabKey({ ids: tabs.ids(), id, event, select: tabs.select, root })
}
const scroll = useTabScroll(tabs.ids, tabs.active)
const root = () => document.querySelector("[data-component=session-tabs] .am-tab-list")
const rows = createMemo(() =>
tabs.ids().map((id) => ({
id,
title: title(id),
active: tabs.active() === id,
busy: working(id),
pending: isPendingTab(id),
})),
)
const freeze = () => setTabWidths(true, document)
const release = () => setTabWidths(false, document)
const close = (id: string, restore = true) => {
@@ -153,6 +162,22 @@ export const SessionTabStrip: Component = () => {
</div>
<div class={`am-tab-fade am-tab-fade-right ${scroll.showRight() ? "am-tab-fade-visible" : ""}`} />
</div>
<div class="session-tab-switcher-wrap">
<SessionTabSwitcher
items={rows}
labels={{
open: language.t("session.tabs.switcher.open"),
search: language.t("session.tabs.switcher.search"),
close: language.t("common.closeTab"),
current: language.t("session.tabs.switcher.current"),
pending: language.t("session.tabs.switcher.pending"),
busy: language.t("session.tabs.switcher.busy"),
}}
onSelect={tabs.select}
onRestore={focusPrompt}
onClose={(id) => close(id, false)}
/>
</div>
</div>
<div class="sr-only" aria-live="polite">
{announcement()}
@@ -0,0 +1,177 @@
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"
import { Spinner } from "@kilocode/kilo-ui/spinner"
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
import { Show, createEffect, createMemo, createSignal, type Component, type JSX } from "solid-js"
interface SessionTabSwitcherItem {
id: string
title: string
active: boolean
busy: boolean
pending: boolean
}
interface SessionTabSwitcherProps {
items: () => SessionTabSwitcherItem[]
labels: {
open: string
search: string
close: string
current: string
pending: string
busy: string
}
onSelect: (id: string) => void
onRestore: () => void
onClose: (id: string) => void
defaultOpen?: boolean
portal?: boolean
}
export const SessionTabSwitcher: Component<SessionTabSwitcherProps> = (props) => {
const [open, setOpen] = createSignal(props.defaultOpen ?? false)
const [notice, setNotice] = createSignal("")
let list: ListRef | undefined
let root: HTMLDivElement | undefined
const current = createMemo(() => props.items().find((item) => item.active))
const focus = (reset = false) =>
queueMicrotask(() => {
if (reset) list?.setFilter("")
root?.querySelector<HTMLInputElement>("input")?.focus({ preventScroll: true })
})
createEffect(() => {
if (open()) focus(true)
})
const select = (item: SessionTabSwitcherItem) => {
setOpen(false)
props.onSelect(item.id)
// Restore the prompt after the closing popover finishes its current event.
queueMicrotask(props.onRestore)
}
const remove = (item: SessionTabSwitcherItem) => {
const last = props.items().length === 2
props.onClose(item.id)
setNotice(`${props.labels.close}: ${item.title}`)
if (last) {
queueMicrotask(props.onRestore)
return
}
focus()
}
const key = (event: KeyboardEvent, item: SessionTabSwitcherItem | undefined) => {
const target = event.target
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) return
const node = event.currentTarget
const id = node instanceof HTMLElement ? node.dataset.key : undefined
// With no initial cursor, a row reached by Tab may not be the List's active item.
const value = props.items().find((row) => row.id === id) ?? item
if (!value) return
if (event.key === "Enter") {
event.preventDefault()
select(value)
return
}
if (event.key !== "Delete" && event.key !== "Backspace") return
event.preventDefault()
remove(value)
}
const wrap = (item: SessionTabSwitcherItem, node: JSX.Element) => (
<div class="session-tab-switcher-item">
{node}
<IconButton
icon="close-small"
size="normal"
variant="ghost"
aria-label={`${props.labels.close}: ${item.title}`}
class="session-tab-switcher-close"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
remove(item)
}}
/>
</div>
)
return (
<Tooltip value={props.labels.open} placement="bottom" gutter={8} inactive={open()}>
<Popover
placement="bottom-end"
open={open()}
onOpenChange={setOpen}
modal={false}
portal={props.portal}
class="search-menu-popover session-tab-switcher-popover"
contentLabel={props.labels.open}
triggerAs={IconButton}
triggerProps={{
type: "button",
icon: "bullet-list",
size: "normal",
variant: "ghost",
class: "search-menu-trigger",
"aria-label": props.labels.open,
}}
>
<div ref={root} class="search-menu session-tab-switcher">
<List<SessionTabSwitcherItem>
ref={(value) => {
list = value
}}
items={props.items()}
key={(item) => item.id}
filterKeys={["title"]}
current={current()}
noInitialSelection
search={{ placeholder: props.labels.search, autofocus: true }}
onKeyEvent={key}
onMove={(item) => setNotice(item ? item.title : "")}
onSelect={(item) => {
if (item) select(item)
}}
itemWrapper={wrap}
>
{(item) => (
<span class="search-menu-row">
<span class="search-menu-icon">
<Show when={!item.busy} fallback={<Spinner class="search-menu-spinner" />}>
<Icon name="speech-bubble" size="small" />
</Show>
</span>
<span class="search-menu-copy">
<span class="search-menu-title" dir="auto">
{item.title}
</span>
<Show when={item.busy || item.pending}>
<span class="search-menu-meta session-tab-switcher-meta">
<Show when={item.busy} fallback={props.labels.pending}>
{props.labels.busy}
</Show>
</span>
</Show>
</span>
<Show when={item.active}>
<span class="search-menu-status session-tab-switcher-status">{props.labels.current}</span>
</Show>
</span>
)}
</List>
<div class="sr-only" role="status" aria-live="polite" aria-atomic="true">
{notice()}
</div>
</div>
</Popover>
</Tooltip>
)
}
+5
View File
@@ -1102,6 +1102,11 @@ export const dict = {
"session.showHistory": "عرض السجل",
"session.search.placeholder": "البحث في الجلسات...",
"session.empty": "لا توجد جلسات بعد. انقر + لبدء محادثة جديدة.",
"session.tabs.switcher.open": "إظهار التبويبات المفتوحة",
"session.tabs.switcher.search": "البحث في التبويبات المفتوحة...",
"session.tabs.switcher.current": "الحالي",
"session.tabs.switcher.pending": "جديد",
"session.tabs.switcher.busy": "جارٍ العمل",
"session.tab.local": "محلي",
"session.tab.cloud": "السحابة",
"session.cloud.repoOnly": "هذا المستودع فقط",
+5
View File
@@ -1123,6 +1123,11 @@ export const dict = {
"session.showHistory": "Mostrar Histórico",
"session.search.placeholder": "Buscar sessões...",
"session.empty": "Nenhuma sessão ainda. Clique + para iniciar uma nova conversa.",
"session.tabs.switcher.open": "Mostrar abas abertas",
"session.tabs.switcher.search": "Buscar abas abertas...",
"session.tabs.switcher.current": "Atual",
"session.tabs.switcher.pending": "Nova",
"session.tabs.switcher.busy": "Trabalhando",
"session.tab.local": "Local",
"session.tab.cloud": "Nuvem",
"session.cloud.repoOnly": "Apenas este repositório",
+5
View File
@@ -1170,6 +1170,11 @@ export const dict = {
"session.showHistory": "Prikaži historiju",
"session.search.placeholder": "Pretraži sesije...",
"session.empty": "Još nema sesija. Kliknite + za početak novog razgovora.",
"session.tabs.switcher.open": "Prikaži otvorene kartice",
"session.tabs.switcher.search": "Pretraži otvorene kartice...",
"session.tabs.switcher.current": "Trenutno",
"session.tabs.switcher.pending": "Novo",
"session.tabs.switcher.busy": "Radi",
"session.tab.local": "Lokalno",
"session.tab.cloud": "Oblak",
"session.cloud.repoOnly": "Samo ovaj repozitorij",
+5
View File
@@ -1162,6 +1162,11 @@ export const dict = {
"session.showHistory": "Vis historik",
"session.search.placeholder": "Søg sessioner...",
"session.empty": "Ingen sessioner endnu. Klik + for at starte en ny samtale.",
"session.tabs.switcher.open": "Vis åbne faner",
"session.tabs.switcher.search": "Søg i åbne faner...",
"session.tabs.switcher.current": "Aktuel",
"session.tabs.switcher.pending": "Ny",
"session.tabs.switcher.busy": "Arbejder",
"session.tab.local": "Lokal",
"session.tab.cloud": "Sky",
"session.cloud.repoOnly": "Kun dette repository",
@@ -1182,6 +1182,11 @@ export const dict = {
"session.showHistory": "Verlauf anzeigen",
"session.search.placeholder": "Sitzungen suchen...",
"session.empty": "Noch keine Sitzungen. Klicke + um eine neue Unterhaltung zu starten.",
"session.tabs.switcher.open": "Offene Tabs anzeigen",
"session.tabs.switcher.search": "Offene Tabs suchen...",
"session.tabs.switcher.current": "Aktuell",
"session.tabs.switcher.pending": "Neu",
"session.tabs.switcher.busy": "In Arbeit",
"session.tab.local": "Lokal",
"session.tab.cloud": "Cloud",
"session.cloud.repoOnly": "Nur dieses Repository",
@@ -1077,6 +1077,11 @@ export const dict = {
"session.history.sources": "History source",
"session.search.placeholder": "Search sessions...",
"session.empty": "No sessions yet. Click + to start a new conversation.",
"session.tabs.switcher.open": "Show open tabs",
"session.tabs.switcher.search": "Search open tabs...",
"session.tabs.switcher.current": "Current",
"session.tabs.switcher.pending": "New",
"session.tabs.switcher.busy": "Working",
"session.tab.local": "Local",
"session.tab.cloud": "Cloud",
"session.cloud.repoOnly": "Only this repository",
+5
View File
@@ -1174,6 +1174,11 @@ export const dict = {
"session.showHistory": "Mostrar historial",
"session.search.placeholder": "Buscar sesiones...",
"session.empty": "Aún no hay sesiones. Haz clic en + para iniciar una nueva conversación.",
"session.tabs.switcher.open": "Mostrar pestañas abiertas",
"session.tabs.switcher.search": "Buscar pestañas abiertas...",
"session.tabs.switcher.current": "Actual",
"session.tabs.switcher.pending": "Nueva",
"session.tabs.switcher.busy": "Trabajando",
"session.tab.local": "Local",
"session.tab.cloud": "Nube",
"session.cloud.repoOnly": "Solo este repositorio",
+5
View File
@@ -1181,6 +1181,11 @@ export const dict = {
"session.showHistory": "Afficher l'historique",
"session.search.placeholder": "Rechercher des sessions...",
"session.empty": "Aucune session pour l'instant. Cliquez + pour démarrer une nouvelle conversation.",
"session.tabs.switcher.open": "Afficher les onglets ouverts",
"session.tabs.switcher.search": "Rechercher dans les onglets ouverts...",
"session.tabs.switcher.current": "Actuel",
"session.tabs.switcher.pending": "Nouveau",
"session.tabs.switcher.busy": "En cours",
"session.tab.local": "Local",
"session.tab.cloud": "Cloud",
"session.cloud.repoOnly": "Uniquement ce dépôt",
+5
View File
@@ -928,6 +928,11 @@ export const dict = {
"session.history.sources": "Origine cronologia",
"session.search.placeholder": "Cerca sessioni...",
"session.empty": "Ancora nessuna sessione. Fai clic su + per avviare una nuova conversazione.",
"session.tabs.switcher.open": "Mostra schede aperte",
"session.tabs.switcher.search": "Cerca schede aperte...",
"session.tabs.switcher.current": "Corrente",
"session.tabs.switcher.pending": "Nuova",
"session.tabs.switcher.busy": "In corso",
"session.tab.local": "Locale",
"session.tab.cloud": "Cloud",
"session.cloud.repoOnly": "Solo questa repository",
+5
View File
@@ -1157,6 +1157,11 @@ export const dict = {
"session.showHistory": "履歴を表示",
"session.search.placeholder": "セッションを検索...",
"session.empty": "セッションがありません。+ をクリックして新しい会話を始めましょう。",
"session.tabs.switcher.open": "開いているタブを表示",
"session.tabs.switcher.search": "開いているタブを検索...",
"session.tabs.switcher.current": "現在",
"session.tabs.switcher.pending": "新規",
"session.tabs.switcher.busy": "作業中",
"session.tab.local": "ローカル",
"session.tab.cloud": "クラウド",
"session.cloud.repoOnly": "このリポジトリのみ",
+5
View File
@@ -1111,6 +1111,11 @@ export const dict = {
"session.showHistory": "기록 보기",
"session.search.placeholder": "세션 검색...",
"session.empty": "아직 세션이 없습니다. +를 클릭하여 새 대화를 시작하세요.",
"session.tabs.switcher.open": "열린 탭 표시",
"session.tabs.switcher.search": "열린 탭 검색...",
"session.tabs.switcher.current": "현재",
"session.tabs.switcher.pending": "새 항목",
"session.tabs.switcher.busy": "작업 중",
"session.tab.local": "로컬",
"session.tab.cloud": "클라우드",
"session.cloud.repoOnly": "이 저장소만",
+5
View File
@@ -1117,6 +1117,11 @@ export const dict = {
"session.showHistory": "Geschiedenis weergeven",
"session.search.placeholder": "Zoek sessies...",
"session.empty": "Nog geen sessies. Klik op + om een nieuw gesprek te starten.",
"session.tabs.switcher.open": "Open tabbladen tonen",
"session.tabs.switcher.search": "Open tabbladen zoeken...",
"session.tabs.switcher.current": "Huidig",
"session.tabs.switcher.pending": "Nieuw",
"session.tabs.switcher.busy": "Bezig",
"session.tab.local": "Lokaal",
"session.tab.cloud": "Cloud",
"session.cloud.repoOnly": "Alleen deze repository",
+5
View File
@@ -1123,6 +1123,11 @@ export const dict = {
"session.showHistory": "Vis historikk",
"session.search.placeholder": "Søk i sesjoner...",
"session.empty": "Ingen sesjoner ennå. Klikk + for å starte en ny samtale.",
"session.tabs.switcher.open": "Vis åpne faner",
"session.tabs.switcher.search": "Søk i åpne faner...",
"session.tabs.switcher.current": "Gjeldende",
"session.tabs.switcher.pending": "Ny",
"session.tabs.switcher.busy": "Jobber",
"session.tab.local": "Lokal",
"session.tab.cloud": "Sky",
"session.cloud.repoOnly": "Kun dette repositoriet",
+5
View File
@@ -1121,6 +1121,11 @@ export const dict = {
"session.showHistory": "Pokaż historię",
"session.search.placeholder": "Szukaj sesji...",
"session.empty": "Brak sesji. Kliknij + aby rozpocząć nową rozmowę.",
"session.tabs.switcher.open": "Pokaż otwarte karty",
"session.tabs.switcher.search": "Szukaj otwartych kart...",
"session.tabs.switcher.current": "Bieżąca",
"session.tabs.switcher.pending": "Nowa",
"session.tabs.switcher.busy": "Pracuje",
"session.tab.local": "Lokalny",
"session.tab.cloud": "Chmura",
"session.cloud.repoOnly": "Tylko to repozytorium",
+5
View File
@@ -1167,6 +1167,11 @@ export const dict = {
"session.showHistory": "Показать историю",
"session.search.placeholder": "Поиск сессий...",
"session.empty": "Сессий пока нет. Нажмите + чтобы начать новый разговор.",
"session.tabs.switcher.open": "Показать открытые вкладки",
"session.tabs.switcher.search": "Поиск открытых вкладок...",
"session.tabs.switcher.current": "Текущая",
"session.tabs.switcher.pending": "Новая",
"session.tabs.switcher.busy": "В работе",
"session.tab.local": "Локальный",
"session.tab.cloud": "Облако",
"session.cloud.repoOnly": "Только этот репозиторий",
+5
View File
@@ -1151,6 +1151,11 @@ export const dict = {
"session.showHistory": "แสดงประวัติ",
"session.search.placeholder": "ค้นหาเซสชัน...",
"session.empty": "ยังไม่มีเซสชัน คลิก + เพื่อเริ่มการสนทนาใหม่",
"session.tabs.switcher.open": "แสดงแท็บที่เปิดอยู่",
"session.tabs.switcher.search": "ค้นหาแท็บที่เปิดอยู่...",
"session.tabs.switcher.current": "ปัจจุบัน",
"session.tabs.switcher.pending": "ใหม่",
"session.tabs.switcher.busy": "กำลังทำงาน",
"session.tab.local": "ในเครื่อง",
"session.tab.cloud": "คลาวด์",
"session.cloud.repoOnly": "เฉพาะรีโพซิทอรีนี้",
+5
View File
@@ -1113,6 +1113,11 @@ export const dict = {
"session.showHistory": "Geçmişi Göster",
"session.search.placeholder": "Oturum ara...",
"session.empty": "Henüz oturum yok. Yeni bir sohbet başlatmak için + tıklayın.",
"session.tabs.switcher.open": "Açık sekmeleri göster",
"session.tabs.switcher.search": "Açık sekmelerde ara...",
"session.tabs.switcher.current": "Geçerli",
"session.tabs.switcher.pending": "Yeni",
"session.tabs.switcher.busy": "Çalışıyor",
"session.tab.local": "Local",
"session.tab.cloud": "Cloud",
"session.cloud.repoOnly": "Yalnızca bu depo",
+5
View File
@@ -1114,6 +1114,11 @@ export const dict = {
"session.showHistory": "Показати історію",
"session.search.placeholder": "Пошук сесій...",
"session.empty": "Сесій поки немає. Натисніть +, щоб почати новий чат.",
"session.tabs.switcher.open": "Показати відкриті вкладки",
"session.tabs.switcher.search": "Пошук відкритих вкладок...",
"session.tabs.switcher.current": "Поточна",
"session.tabs.switcher.pending": "Нова",
"session.tabs.switcher.busy": "Працює",
"session.tab.local": "Локальний",
"session.tab.cloud": "Хмарний",
"session.cloud.repoOnly": "Лише цей репозиторій",
+5
View File
@@ -1127,6 +1127,11 @@ export const dict = {
"session.showHistory": "显示历史",
"session.search.placeholder": "搜索会话...",
"session.empty": "暂无会话。点击 + 开始新的对话。",
"session.tabs.switcher.open": "显示打开的标签页",
"session.tabs.switcher.search": "搜索打开的标签页...",
"session.tabs.switcher.current": "当前",
"session.tabs.switcher.pending": "新建",
"session.tabs.switcher.busy": "工作中",
"session.tab.local": "本地",
"session.tab.cloud": "云端",
"session.cloud.repoOnly": "仅此仓库",
+5
View File
@@ -1088,6 +1088,11 @@ export const dict = {
"session.showHistory": "顯示歷史",
"session.search.placeholder": "搜尋工作階段...",
"session.empty": "尚無工作階段。點選 + 以開始新的對話。",
"session.tabs.switcher.open": "顯示開啟的分頁",
"session.tabs.switcher.search": "搜尋開啟的分頁...",
"session.tabs.switcher.current": "目前",
"session.tabs.switcher.pending": "新增",
"session.tabs.switcher.busy": "工作中",
"session.tab.local": "本機",
"session.tab.cloud": "雲端",
"session.cloud.repoOnly": "僅此儲存庫",
@@ -0,0 +1,60 @@
/** @jsxImportSource solid-js */
import type { Meta, StoryObj } from "storybook-solidjs-vite"
import { SessionTabSwitcher } from "../components/chat/SessionTabSwitcher"
import { StoryProviders } from "./StoryProviders"
const rows = [
{ id: "refactor", title: "Refactor shared search menu styles", active: false, busy: false, pending: false },
{ id: "current", title: "Run the extension test suite", active: true, busy: true, pending: false },
{ id: "pending", title: "Untitled session", active: false, busy: false, pending: true },
{ id: "idle", title: "Review keyboard navigation behavior", active: false, busy: false, pending: false },
]
const noop = () => {}
const focus = () => document.querySelector<HTMLTextAreaElement>('[data-slot="session-prompt-focus-target"]')?.focus()
const meta: Meta = {
title: "Session Tabs",
parameters: { layout: "fullscreen" },
}
export default meta
type Story = StoryObj
export const SwitcherOpen: Story = {
name: "Session tab switcher — open",
render: () => (
<StoryProviders noPadding>
<div
style={{
display: "flex",
"min-height": "420px",
"justify-content": "flex-end",
"align-items": "flex-start",
padding: "16px",
background: "var(--surface-base)",
}}
>
<textarea class="sr-only" aria-label="Chat input" data-slot="session-prompt-focus-target" />
<div class="session-tab-switcher-wrap">
<SessionTabSwitcher
items={() => rows}
labels={{
open: "Show open tabs",
search: "Search open tabs",
close: "Close tab",
current: "Current",
pending: "New",
busy: "Working",
}}
onSelect={noop}
onRestore={focus}
onClose={noop}
defaultOpen
portal={false}
/>
</div>
</div>
</StoryProviders>
),
}
@@ -7,6 +7,7 @@
@import "./session-title-editor.css";
@import "./task-header.css";
@import "./search-menu.css";
@import "./session-tabs.css";
@import "./chat-layout.css";
@import "./banners.css";
@@ -0,0 +1,92 @@
/* Shared searchable popovers */
.search-menu-trigger[data-component="icon-button"][data-expanded] {
background: var(--surface-base-hover);
}
.search-menu-popover[data-component="popover-content"] {
width: min(320px, calc(100vw - 24px));
}
.search-menu [data-component="list"] {
gap: 0;
padding: 8px 0 0;
}
.search-menu [data-slot="list-search-wrapper"] {
margin: 0 0 4px;
padding: 0 8px;
}
.search-menu [data-slot="list-search"] {
background: var(--surface-inset-base);
}
.search-menu [data-slot="list-scroll"] {
gap: 2px;
}
.search-menu [data-slot="list-item"][data-active="true"],
.search-menu [data-slot="list-item"][data-selected="true"] {
background: color-mix(in srgb, var(--text-base) 10%, transparent);
}
.search-menu-row {
display: flex;
width: 100%;
min-width: 0;
align-items: center;
gap: 8px;
}
.search-menu-icon {
display: inline-flex;
width: 18px;
height: 18px;
flex-shrink: 0;
align-items: center;
justify-content: center;
color: var(--text-weak);
}
.search-menu-spinner {
width: 14px;
height: 14px;
}
.search-menu-copy {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 1px;
text-align: start;
}
.search-menu-title,
.search-menu-meta {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.search-menu-title {
color: var(--text-base);
font-size: var(--kilo-font-size-12);
font-weight: 500;
line-height: 1.35;
}
.search-menu-meta {
font-size: var(--kilo-font-size-10);
font-weight: 400;
line-height: 1.35;
}
.search-menu-status {
flex-shrink: 0;
margin-inline-start: auto;
color: var(--text-base);
font-size: var(--kilo-font-size-10);
font-weight: 400;
}
@@ -228,6 +228,75 @@
color: var(--text-base);
}
.session-tab-switcher-wrap {
display: flex;
align-items: center;
flex-shrink: 0;
margin-inline-start: 4px;
padding-inline: 6px 4px;
border-inline-start: 1px solid var(--border-weak-base);
}
.session-tab-switcher-popover[data-component="popover-content"] {
max-height: min(420px, calc(100vh - 64px));
}
.session-tab-switcher [data-component="list"] {
max-height: min(390px, calc(100vh - 88px));
}
.session-tab-switcher [data-slot="list-scroll"] {
max-height: 320px;
padding: 0 4px 4px;
}
.session-tab-switcher [data-slot="list-item"] {
min-height: 42px;
padding-block: 6px;
padding-inline: 8px 36px;
}
.session-tab-switcher [data-slot="list-item-selected-icon"] {
display: none;
}
.session-tab-switcher-item {
position: relative;
width: 100%;
min-width: 0;
}
.session-tab-switcher-meta {
color: var(--text-weak);
}
.session-tab-switcher-status {
color: var(--text-weak);
font-weight: 500;
line-height: 1;
text-transform: uppercase;
}
.session-tab-switcher-close[data-component="icon-button"] {
position: absolute;
inset-block-start: 50%;
inset-inline-end: 4px;
opacity: 0;
pointer-events: none;
transform: translateY(-50%);
transition: opacity 100ms ease;
}
.session-tab-switcher-item:hover .session-tab-switcher-close[data-component="icon-button"],
.session-tab-switcher-item:focus-within .session-tab-switcher-close[data-component="icon-button"] {
opacity: 1;
pointer-events: auto;
}
.session-tab-switcher-close[data-component="icon-button"] [data-slot="icon-svg"] {
color: var(--text-base);
}
/* Match tab context menus to dropdown-menu visuals on every tab surface. */
.session-tab-menu {
min-width: 210px;
+4 -2
View File
@@ -175,14 +175,16 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
const all = flat()
const selected = all.find((x) => props.key(x) === active())
const index = selected ? all.indexOf(selected) : -1
props.onKeyEvent?.(e, selected)
if (e.defaultPrevented) return
if (e.key === "Enter" && !e.isComposing) {
e.preventDefault()
if (selected) handleSelect(selected, index)
// kilocode_change start - fall back to first result when no item is active (noInitialSelection)
const target = selected ?? all[0]
if (target) handleSelect(target, all.indexOf(target))
// kilocode_change end
} else if (props.search) {
if (e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && (e.key === "n" || e.key === "p")) {
onKeyDown(e)
+3
View File
@@ -20,6 +20,7 @@ export interface PopoverProps<T extends ValidComponent = "div">
trigger?: JSXElement
triggerAs?: T
triggerProps?: ComponentProps<T>
contentLabel?: string // kilocode_change
title?: JSXElement
description?: JSXElement
class?: ComponentProps<"div">["class"]
@@ -34,6 +35,7 @@ export function Popover<T extends ValidComponent = "div">(props: PopoverProps<T>
"trigger",
"triggerAs",
"triggerProps",
"contentLabel", // kilocode_change
"title",
"description",
"class",
@@ -136,6 +138,7 @@ export function Popover<T extends ValidComponent = "div">(props: PopoverProps<T>
<Kobalte.Content
ref={(el: HTMLElement | undefined) => setState("contentRef", el)}
data-component="popover-content"
aria-label={local.contentLabel /* kilocode_change */}
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,