mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-29 03:32:08 +08:00
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:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Add a searchable open-tabs switcher to the sidebar tab bar.
|
||||
+3
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user