Files
kilocode/packages/kilo-vscode/webview-ui/agent-manager/SidebarSearchMenu.tsx
T
hdcode.dev 8eeaa546ae 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>
2026-07-23 09:08:02 +00:00

172 lines
6.7 KiB
TypeScript

/** @jsxImportSource solid-js */
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"
import { Spinner } from "@kilocode/kilo-ui/spinner"
import { TooltipKeybind } from "@kilocode/kilo-ui/tooltip"
import { formatRelativeDate } from "../src/utils/date"
import { colorCss } from "./section-colors"
import type { SidebarSearchItem } from "./sidebar-search"
export interface SidebarSearchMenuRef {
open: () => void
}
interface SidebarSearchMenuProps {
items: Accessor<SidebarSearchItem[]>
current: Accessor<SidebarSearchItem | undefined>
labels: { search: string; scope: string; contexts: string; sessions: string; waiting: string; retry: string }
keybind: string
ref?: (value: SidebarSearchMenuRef) => void
onSelect: (item: SidebarSearchItem) => void
defaultOpen?: boolean
portal?: boolean
}
export const SidebarSearchMenu: Component<SidebarSearchMenuProps> = (props) => {
const [open, setOpen] = createSignal(props.defaultOpen ?? false)
const [query, setQuery] = createSignal("")
const [notice, setNotice] = createSignal("")
let list: ListRef | undefined
let root: HTMLDivElement | undefined
const focus = () =>
queueMicrotask(() => {
list?.setFilter("")
root?.querySelector<HTMLInputElement>("input")?.focus({ preventScroll: true })
})
createEffect(() => {
if (open()) focus()
})
const reveal = () => {
setOpen(true)
focus()
}
props.ref?.({ open: reveal })
const refocus = () =>
requestAnimationFrame(() => window.dispatchEvent(new CustomEvent("focusPrompt", { detail: { restore: true } })))
const close = (next: boolean) => {
setOpen(next)
if (!next) refocus()
}
const select = (item: SidebarSearchItem) => {
setOpen(false)
props.onSelect(item)
refocus()
}
return (
<TooltipKeybind title={props.labels.scope} keybind={props.keybind} placement="top" gutter={8} inactive={open()}>
<Popover
placement="bottom-start"
open={open()}
onOpenChange={close}
modal={false}
portal={props.portal}
class="search-menu-popover am-sidebar-search-popover"
contentLabel={props.labels.search}
triggerAs={IconButton}
triggerProps={{
type: "button",
icon: "magnifying-glass",
size: "normal",
variant: "ghost",
class: "search-menu-trigger",
"aria-label": props.labels.search,
}}
>
<div ref={root} class="search-menu am-sidebar-search" data-agent-manager-native-text-shortcuts>
<List<SidebarSearchItem>
ref={(value) => {
list = value
}}
items={props.items()}
key={(item) => item.key}
filterKeys={["title", "search"]}
current={props.current()}
groupBy={(item) => (query() ? "" : item.group)}
groupHeader={(group) => (group.category === "sessions" ? props.labels.sessions : props.labels.contexts)}
sortGroupsBy={(a, b) => (a.category === "sessions" ? -1 : b.category === "sessions" ? 1 : 0)}
search={{ placeholder: props.labels.search }}
onFilter={setQuery}
onMove={(item) => setNotice(item ? [item.title, ...item.meta].join(", ") : "")}
onSelect={(item) => {
if (item) select(item)
}}
>
{(item) => {
const working = item.state === "busy" || item.state === "retry"
return (
<span
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="search-menu-icon">
<Show when={!working} fallback={<Spinner class="search-menu-spinner" />}>
<Show
when={item.kind !== "local"}
fallback={
<svg class="am-local-icon" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<rect x="2.5" y="3.5" width="15" height="10" rx="1" stroke="currentColor" />
<path d="M6 16.5H14M10 13.5V16.5" stroke="currentColor" />
</svg>
}
>
<Icon name={item.kind === "worktree" ? "branch" : "speech-bubble"} size="small" />
</Show>
</Show>
</span>
<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
class="am-sidebar-search-swatch"
style={{ background: colorCss(section().color) ?? "var(--border-weak-base)" }}
/>
)}
</Show>
<span>{item.meta.join(" · ")}</span>
</span>
</span>
<Show when={item.state === "waiting"}>
<span class="search-menu-status am-sidebar-search-status">{props.labels.waiting}</span>
</Show>
<Show when={item.state === "retry"}>
<span class="search-menu-status am-sidebar-search-status">{props.labels.retry}</span>
</Show>
<Show when={item.kind !== "session" && item.state === "idle"}>
<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="search-menu-status">{formatRelativeDate(item.updatedAt)}</span>
</Show>
</span>
)
}}
</List>
<div class="sr-only" role="status" aria-live="polite" aria-atomic="true">
{notice()}
</div>
</div>
</Popover>
</TooltipKeybind>
)
}