Merge branch 'main' into dependabot-babel-core

This commit is contained in:
Andrea Giammarchi
2026-08-27 11:51:37 +02:00
committed by GitHub
18 changed files with 789 additions and 47 deletions
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2f39a67f1abfc391d9784d0c0dec6abb33fc8940ce6c6bed07cc47f2ebcff0f5
size 11599
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ee3282e45fbd929ec6f39c56dd9db08d6b5952b44a77245173d4bcf0eb58791a
size 11601
@@ -27,17 +27,18 @@ class FakeElement {
scrollTop = 0
style = { overflowAnchor: "" }
hovered = false
control = false
dir = ""
rect = { left: 0, top: 0, right: 100, bottom: 100 }
ownerDocument!: FakeDocument
private children = new Set<FakeElement>()
private listeners = new Map<string, Listener[]>()
closest() {
return null
closest(selector: string) {
return this.control && selector === "button, input, textarea, select" ? this : null
}
contains(node: unknown) {
contains(node: unknown): boolean {
return node === this || (node instanceof FakeElement && [...this.children].some((child) => child.contains(node)))
}
@@ -313,6 +314,140 @@ describe("createAutoScroll non-scrollable layouts", () => {
ctx.dispose()
})
test.each([0, 0.5, 1])("preserves upward intent after a %spx scroll near the bottom", (offset) => {
const ctx = setup({ working: true })
overflow(ctx)
let now = 10
const clock = spyOn(performance, "now").mockImplementation(() => now)
try {
ctx.el.fire("wheel", new FakeWheelEvent(-1, ctx.el) as unknown as Event)
ctx.el.scrollTop -= offset
ctx.scroll.handleScroll()
expect(ctx.scroll.userScrolled()).toBe(true)
now = 500
ctx.scroll.handleScroll()
ctx.el.scrollHeight = 1100
ctx.mutate()
ctx.resize()
expect(ctx.scroll.userScrolled()).toBe(true)
expect(ctx.el.scrollTop).toBe(800 - offset)
} finally {
clock.mockRestore()
ctx.dispose()
}
})
test("pauses for upward wheel input over a transcript button", () => {
const ctx = setup({ working: true })
overflow(ctx)
const button = new FakeElement()
button.control = true
ctx.el.append(button)
ctx.el.fire("wheel", new FakeWheelEvent(-240, button) as unknown as Event)
expect(ctx.scroll.userScrolled()).toBe(true)
ctx.el.scrollTop = 560
ctx.scroll.handleScroll()
ctx.el.scrollHeight = 1100
ctx.mutate()
ctx.resize()
expect(ctx.scroll.userScrolled()).toBe(true)
expect(ctx.el.scrollTop).toBe(560)
ctx.dispose()
})
test("does not treat button clicks and key presses as scroll input", () => {
const ctx = setup({ working: true })
overflow(ctx)
const button = new FakeElement()
button.control = true
ctx.el.append(button)
ctx.el.fire("pointerdown", new FakePointerEvent(1, button) as unknown as Event)
ctx.doc.fire("keydown", new FakeKeyboardEvent("ArrowUp", button) as unknown as Event)
ctx.el.scrollTop = 600
ctx.scroll.handleScroll()
expect(ctx.scroll.userScrolled()).toBe(false)
expect(ctx.el.scrollTop).toBe(1000)
ctx.dispose()
})
test("keeps a pause when a layout change puts the same position at the bottom", () => {
const ctx = setup({ working: true })
overflow(ctx, 1000, 400)
ctx.scroll.pause()
ctx.el.scrollHeight = 600
ctx.scroll.handleScroll()
ctx.el.scrollHeight = 1000
ctx.mutate()
ctx.resize()
expect(ctx.scroll.userScrolled()).toBe(true)
expect(ctx.el.scrollTop).toBe(400)
ctx.dispose()
})
test.each(["wheel", "keyboard"])("preserves new %s input before a pending bottom scroll", (input) => {
const ctx = setup({ working: true })
overflow(ctx)
let now = 10
const clock = spyOn(performance, "now").mockImplementation(() => now)
try {
ctx.el.fire("wheel", new FakeWheelEvent(-20, ctx.el) as unknown as Event)
ctx.el.scrollTop = 780
ctx.scroll.handleScroll()
expect(ctx.scroll.userScrolled()).toBe(true)
ctx.el.scrollTop = 800
if (input === "wheel") ctx.el.fire("wheel", new FakeWheelEvent(-20, ctx.el) as unknown as Event)
if (input === "keyboard") {
ctx.doc.fire("keydown", new FakeKeyboardEvent("ArrowUp", ctx.el) as unknown as Event)
}
ctx.scroll.handleScroll()
expect(ctx.scroll.userScrolled()).toBe(true)
ctx.el.scrollTop = 780
ctx.scroll.handleScroll()
now = 500
ctx.el.scrollHeight = 1100
ctx.mutate()
ctx.resize()
expect(ctx.scroll.userScrolled()).toBe(true)
expect(ctx.el.scrollTop).toBe(780)
} finally {
clock.mockRestore()
ctx.dispose()
}
})
test("reattaches when a downward wheel returns to the bottom", () => {
const ctx = setup({ working: true })
overflow(ctx)
ctx.el.fire("wheel", new FakeWheelEvent(-20, ctx.el) as unknown as Event)
ctx.el.scrollTop = 780
ctx.scroll.handleScroll()
expect(ctx.scroll.userScrolled()).toBe(true)
ctx.el.fire("wheel", new FakeWheelEvent(20, ctx.el) as unknown as Event)
ctx.el.scrollTop = 800
ctx.scroll.handleScroll()
expect(ctx.scroll.userScrolled()).toBe(false)
ctx.el.scrollHeight = 1100
ctx.mutate()
expect(ctx.el.scrollTop).toBe(1100)
ctx.dispose()
})
test("continues following streaming growth after a downward wheel at the bottom", () => {
const ctx = setup({ working: true })
ctx.el.scrollHeight = 1000
@@ -634,6 +769,33 @@ describe("createAutoScroll non-scrollable layouts", () => {
ctx.dispose()
})
test("keeps a scrollbar gesture active after reaching the bottom", () => {
const ctx = setup({ working: true })
overflow(ctx)
let now = 10
const clock = spyOn(performance, "now").mockImplementation(() => now)
try {
ctx.doc.fire("pointerdown", new FakePointerEvent(1, ctx.el) as unknown as Event)
ctx.el.scrollTop = 600
ctx.scroll.handleScroll()
expect(ctx.scroll.userScrolled()).toBe(true)
ctx.el.scrollTop = 800
ctx.scroll.handleScroll()
expect(ctx.scroll.userScrolled()).toBe(false)
now = 1000
ctx.el.scrollTop = 600
ctx.scroll.handleScroll()
expect(ctx.scroll.userScrolled()).toBe(true)
expect(ctx.el.scrollTop).toBe(600)
} finally {
clock.mockRestore()
ctx.dispose()
}
})
test("keeps a pointer gesture active beyond the grace period", () => {
const ctx = setup({ working: true })
overflow(ctx)
@@ -22,6 +22,7 @@ export function createAutoScroll(options: AutoScrollOptions) {
// ---------------------------------------------------------------------------
let scroll: HTMLElement | undefined
let top = 0
let settling = false
let settleTimer: ReturnType<typeof setTimeout> | undefined
let cleanup: (() => void) | undefined
@@ -73,7 +74,9 @@ export function createAutoScroll(options: AutoScrollOptions) {
}
const pause = () => {
if (!scroll || store.userScrolled) return
if (!scroll) return
top = scroll.scrollTop
if (store.userScrolled) return
setStore("userScrolled", true)
options.onUserInteracted?.()
}
@@ -91,7 +94,7 @@ export function createAutoScroll(options: AutoScrollOptions) {
grace: USER_INTERACTION_GRACE_MS,
// Upward wheel input anywhere in the transcript expresses the user's
// intent to review earlier content, even when a nested region consumes it.
onWheelUp: stop,
onUp: stop,
})
// ---------------------------------------------------------------------------
@@ -101,13 +104,19 @@ export function createAutoScroll(options: AutoScrollOptions) {
const handleScroll = () => {
if (!scroll) return
const position = scroll.scrollTop
const down = position > top
top = position
const input = userActivity.consumeScroll()
const distance = distanceFromBottom(scroll)
if (!canScroll(scroll)) return
if (distance < threshold()) {
if (store.userScrolled && (distance < 2 || !userActivity.isRecent())) setStore("userScrolled", false)
if (store.userScrolled && down && (distance < 2 || !userActivity.isRecent())) {
userActivity.clear()
setStore("userScrolled", false)
}
return
}
@@ -187,7 +196,7 @@ export function createAutoScroll(options: AutoScrollOptions) {
settleTimer = undefined
if (working) {
force()
follow()
return
}
@@ -234,6 +243,7 @@ export function createAutoScroll(options: AutoScrollOptions) {
}
scroll = el
top = el?.scrollTop ?? 0
setStore("scrollRef", el)
if (!el) return
@@ -1,6 +1,6 @@
interface UserActivityOptions {
grace: number
onWheelUp: () => void
onUp: () => void
}
type Kind = "pointer" | "mouse" | "touch"
@@ -94,19 +94,22 @@ export const createUserActivity = (options: UserActivityOptions) => {
gestures.delete(doc)
}
const reset = () => {
if (doc && scroll && gestures.get(doc) === scroll) gestures.delete(doc)
const clear = () => {
marked = false
time = 0
}
const reset = () => {
if (doc && scroll && gestures.get(doc) === scroll) gestures.delete(doc)
clear()
gesture = undefined
}
const wheel = (event: WheelEvent) => {
if (!isPotentialScrollInput(event)) return
if (!scroll || scroll.scrollHeight - scroll.clientHeight <= 1) return
if (event.deltaY >= 0 || scroll.scrollTop <= 0) return
mark(event)
options.onWheelUp()
mark()
options.onUp()
}
const key = (event: KeyboardEvent) => {
@@ -125,6 +128,7 @@ export const createUserActivity = (options: UserActivityOptions) => {
})
if (deepest(matches) !== scroll) return
mark(event)
if (up) options.onUp()
}
return {
@@ -179,6 +183,7 @@ export const createUserActivity = (options: UserActivityOptions) => {
return value
},
isRecent: () => gesture !== undefined || (time > 0 && performance.now() - time < options.grace),
clear,
reset,
}
}
+8 -2
View File
@@ -751,7 +751,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
localResourceRoots: [this.extensionUri],
}
webviewView.webview.html = this._getHtmlForWebview(webviewView.webview)
webviewView.webview.html = this._getHtmlForWebview(webviewView.webview, true)
this.setupWebviewMessageHandler(webviewView.webview)
this.setSidebarVisible(webviewView.visible)
@@ -5319,8 +5319,14 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
return resolveProjectDirectory(this.projectDirectory, () => this.getWorkspaceDirectory(sessionId))
}
private _getHtmlForWebview(webview: vscode.Webview): string {
private _getHtmlForWebview(webview: vscode.Webview, sidebar = false): string {
return buildWebviewHtml(webview, {
// The rail follows the physical workbench edge. RTL text direction must not move it between chat and code.
sidebar: sidebar
? vscode.workspace.getConfiguration("workbench").get("sideBar.location") === "right"
? "right"
: "left"
: undefined,
scriptUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "webview.js")),
styleUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "webview.css")),
iconsBaseUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "assets", "icons")),
+2 -1
View File
@@ -66,6 +66,7 @@ export function buildWebviewHtml(
topBar?: boolean
topBarSurface?: string
agentManagerSettings?: boolean
sidebar?: "left" | "right"
},
): string {
const nonce = getNonce()
@@ -73,7 +74,7 @@ export function buildWebviewHtml(
const markdownWorkerUri = opts.workerUri.toString().replace(/shiki-worker\.js$/, "markdown-shiki-worker.js")
return `<!DOCTYPE html>
<html lang="en" data-theme="kilo-vscode">
<html lang="en" data-theme="kilo-vscode" data-sidebar="${opts.sidebar ?? ""}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -192,3 +192,145 @@ test("keeps a long native scrollbar drag user-controlled", async ({ page }) => {
await expect.poll(() => distance(page)).toBeGreaterThan(40)
await expect(page.getByRole("button", { name: "Scroll to bottom" })).toBeVisible()
})
test("pauses on an upward wheel over the Copy response button", async ({ page }) => {
await page.goto(`/iframe.html?id=${STORY_ID}&viewMode=story&globals=${GLOBALS}`, { waitUntil: "load" })
const list = page.locator(".message-list")
const copy = page.getByRole("button", { name: "Copy response" }).first()
await expect(list).toBeVisible()
await expect(copy).toBeVisible()
await settle(page, 10)
await expect.poll(() => distance(page)).toBeLessThanOrEqual(2)
await copy.hover()
await page.mouse.wheel(0, -240)
await expect.poll(() => distance(page)).toBeGreaterThan(40)
await expect(page.getByRole("button", { name: "Scroll to bottom" })).toBeVisible()
})
test("keeps a one-pixel upward wheel pause through delayed streaming", async ({ page }) => {
await page.goto(`/iframe.html?id=${STORY_ID}&viewMode=story&globals=${GLOBALS}`, { waitUntil: "load" })
const list = page.locator(".message-list")
const copy = page.getByRole("button", { name: "Copy response" }).first()
await expect(list).toBeVisible()
await expect(copy).toBeVisible()
await settle(page, 10)
await expect.poll(() => distance(page)).toBeLessThanOrEqual(2)
await copy.hover()
await page.mouse.wheel(0, -1)
await expect.poll(() => distance(page)).toBeGreaterThan(0)
const before = await state(page)
await page.waitForTimeout(350)
await page.getByTestId("append-stream").click()
await expect.poll(() => list.evaluate((el) => el.scrollHeight)).toBeGreaterThan(before.height)
await settle(page, 10)
const after = await state(page)
expect(after.top).toBeCloseTo(before.top, 0)
expect(after.distance).toBeGreaterThan(40)
await expect(page.getByRole("button", { name: "Scroll to bottom" })).toBeVisible()
})
test("keeps the pause after a pending bottom scroll event", async ({ page }) => {
await page.goto(`/iframe.html?id=${STORY_ID}&viewMode=story&globals=${GLOBALS}`, { waitUntil: "load" })
const list = page.locator(".message-list")
const copy = page.getByRole("button", { name: "Copy response" }).first()
const bottom = page.getByRole("button", { name: "Scroll to bottom" })
await expect(list).toBeVisible()
await expect(copy).toBeVisible()
await settle(page, 10)
await expect.poll(() => distance(page)).toBeLessThanOrEqual(2)
await list.evaluate((el) => {
const fire = () => {
el.dataset.pending = "1"
el.dispatchEvent(new Event("scroll"))
}
const wheel = (event: Event) => {
if (event.target !== el && event.target instanceof Element && !el.contains(event.target)) return
queueMicrotask(fire)
el.ownerDocument.removeEventListener("wheel", wheel, true)
}
el.dataset.pending = "0"
el.ownerDocument.addEventListener("wheel", wheel, true)
})
await copy.hover()
await page.mouse.wheel(0, -1)
await expect.poll(() => list.getAttribute("data-pending")).toBe("1")
await expect.poll(() => distance(page)).toBeGreaterThan(0)
await expect(bottom).toBeVisible()
})
for (const input of ["wheel", "keyboard"] as const) {
test(`keeps new upward ${input} input before a pending return-to-bottom scroll`, async ({ page }) => {
await page.goto(`/iframe.html?id=${STORY_ID}&viewMode=story&globals=${GLOBALS}`, { waitUntil: "load" })
const list = page.locator(".message-list")
const bottom = page.getByRole("button", { name: "Scroll to bottom" })
await expect(list).toBeVisible()
await settle(page, 10)
await expect.poll(() => distance(page)).toBeLessThanOrEqual(2)
await list.hover()
await page.mouse.wheel(0, -240)
await expect.poll(() => distance(page)).toBeGreaterThan(40)
await expect(bottom).toBeVisible()
await settle(page, 4)
await list.evaluate((el, input) => {
if (input === "keyboard") {
el.tabIndex = 0
el.focus({ preventScroll: true })
}
const type = input === "wheel" ? "wheel" : "keydown"
const prime = () => {
el.scrollTop = el.scrollHeight - el.clientHeight
}
const pending = () => {
el.dispatchEvent(new Event("scroll"))
el.dataset.pending = "1"
}
el.ownerDocument.addEventListener(type, prime, { capture: true, passive: false, once: true })
const target = input === "wheel" ? el : el.ownerDocument
target.addEventListener(type, pending, { capture: input === "wheel", passive: false, once: true })
}, input)
if (input === "wheel") await page.mouse.wheel(0, -20)
if (input === "keyboard") await page.keyboard.press("ArrowUp")
await expect.poll(() => list.getAttribute("data-pending")).toBe("1")
await expect.poll(() => distance(page)).toBeGreaterThan(10)
await expect(bottom).toBeVisible()
await settle(page, 20)
const before = await state(page)
await page.waitForTimeout(350)
await page.getByTestId("append-stream").click()
await expect.poll(() => list.evaluate((el) => el.scrollHeight)).toBeGreaterThan(before.height)
await settle(page, 10)
expect((await state(page)).top).toBeCloseTo(before.top, 0)
await expect(bottom).toBeVisible()
})
}
test("preserves the pause across working status changes", async ({ page }) => {
await page.goto(`/iframe.html?id=${STORY_ID}&viewMode=story&globals=${GLOBALS}`, { waitUntil: "load" })
const list = page.locator(".message-list")
const bottom = page.getByRole("button", { name: "Scroll to bottom" })
await expect(list).toBeVisible()
await settle(page, 10)
await expect.poll(() => distance(page)).toBeLessThanOrEqual(2)
await list.hover()
await page.mouse.wheel(0, -240)
await expect.poll(() => distance(page)).toBeGreaterThan(40)
await expect(bottom).toBeVisible()
await page.getByTestId("toggle-status").click()
await page.getByTestId("toggle-status").click()
await settle(page, 4)
await expect.poll(() => distance(page)).toBeGreaterThan(40)
await expect(bottom).toBeVisible()
})
@@ -0,0 +1,180 @@
import { expect, test, type Page } from "@playwright/test"
const GLOBALS = "colorScheme:dark;theme:kilo-vscode;vscodeTheme:dark-modern"
async function open(page: Page, side: "left" | "right" = "left", width = 420) {
await page.setViewportSize({ width, height: 720 })
await page.goto(`/iframe.html?id=chat--prompt-rail-${side}&viewMode=story&globals=${GLOBALS}`, { waitUntil: "load" })
await expect(page.locator(".prompt-rail-tick")).toHaveCount(5)
await page.evaluate(() => document.fonts.ready)
await page.clock.install({ time: new Date("2026-01-01T00:00:00Z") })
await page.clock.pauseAt(new Date("2026-01-01T00:00:01Z"))
}
for (const side of ["left", "right"] as const) {
for (const width of [200, 420]) {
test(`opens inward from the ${side} edge at ${width}px`, async ({ page }) => {
await open(page, side, width)
if (width === 200) await page.evaluate(() => (document.documentElement.dir = "rtl"))
const rail = page.locator(".prompt-rail")
const lane = await page.locator(".message-list").evaluate((el) => el.clientWidth)
await expect(rail).toHaveAttribute("data-side", side)
await rail.locator(".prompt-rail-tick").first().focus()
const card = page.locator(".prompt-rail-card")
await expect(card).toBeVisible()
await expect(card).toHaveAttribute("data-side", side)
await expect(card).toHaveCSS("transform", "none")
const tick = await rail.boundingBox()
const box = await card.boundingBox()
if (!tick || !box) throw new Error("Prompt navigator geometry is missing")
expect(box.x).toBeGreaterThanOrEqual(12)
expect(box.x + box.width).toBeLessThanOrEqual(width - 12)
expect(box.y).toBeGreaterThanOrEqual(12)
expect(box.y + box.height).toBeLessThanOrEqual(708)
expect(await page.locator(".message-list").evaluate((el) => el.clientWidth)).toBe(lane)
if (side === "left") {
expect(tick.x).toBe(8)
expect(box.x - tick.x - tick.width).toBe(8)
return
}
expect(width - tick.x - tick.width).toBe(8)
expect(tick.x - box.x - box.width).toBe(8)
})
}
}
test("ignores brief crossings and restarts the delay for a different tick", async ({ page }) => {
await open(page)
const ticks = page.locator(".prompt-rail-tick")
const card = page.locator(".prompt-rail-card")
await ticks.first().hover()
await page.clock.runFor(200)
await ticks.nth(1).hover()
await page.clock.runFor(200)
await expect(card).toBeHidden()
await page.getByTestId("prompt-rail-content").hover()
await page.clock.runFor(500)
await expect(card).toBeHidden()
await expect(page.locator(".prompt-rail")).toHaveCSS("opacity", "0.5")
})
test("opens after a deliberate hover and keeps the rail-to-card bridge", async ({ page }) => {
await open(page)
const ticks = page.locator(".prompt-rail-tick")
const card = page.locator(".prompt-rail-card")
await ticks.first().hover()
await page.clock.runFor(349)
await expect(card).toBeHidden()
await page.clock.runFor(1)
await expect(card).toBeVisible()
await ticks.nth(1).hover()
await expect(card.locator('[data-prompt-index="1"]')).toHaveClass(/prompt-rail-row--hover/)
await expect(card).toHaveCSS("transform", "none")
const tick = await ticks.nth(1).boundingBox()
const box = await card.boundingBox()
if (!tick || !box) throw new Error("Prompt navigator geometry is missing")
await page.mouse.move((tick.x + tick.width + box.x) / 2, tick.y + tick.height / 2)
await page.clock.runFor(80)
await card.hover()
await page.clock.runFor(500)
await expect(card).toBeVisible()
await page.getByTestId("prompt-rail-content").hover()
await page.clock.runFor(119)
await expect(card).toBeVisible()
await page.clock.runFor(1)
await expect(card).toBeHidden()
})
test("keeps click and keyboard navigation immediate", async ({ page }) => {
await open(page, "right")
const ticks = page.locator(".prompt-rail-tick")
const host = page.getByTestId("prompt-rail-host")
const card = page.locator(".prompt-rail-card")
await ticks.last().click()
await expect(host).toHaveAttribute("data-selected", "rail-user-5:user")
await expect(card).toBeVisible()
await page.keyboard.press("Escape")
await page.clock.runFor(500)
await expect(card).toBeHidden()
await ticks.first().focus()
await expect(card).toBeVisible()
await page.keyboard.press("End")
await expect(ticks.last()).toBeFocused()
await page.keyboard.press("Home")
await expect(ticks.first()).toBeFocused()
await page.keyboard.press("ArrowDown")
await expect(ticks.nth(1)).toBeFocused()
await page.keyboard.press("Enter")
await expect(host).toHaveAttribute("data-selected", "rail-user-2:user")
await page.keyboard.press("ArrowDown")
await page.keyboard.press("Space")
await expect(host).toHaveAttribute("data-selected", "rail-user-3:user")
await card.getByRole("button", { name: "Latest prompt", exact: true }).click()
await expect(host).toHaveAttribute("data-selected", "rail-user-5:user")
await card.getByRole("button", { name: "First prompt", exact: true }).click()
await expect(host).toHaveAttribute("data-selected", "rail-user-1:user")
await card.locator('[data-prompt-index="3"]').click()
await expect(host).toHaveAttribute("data-selected", "rail-user-4:user")
})
test("Escape dismisses a hover preview before other chat shortcuts", async ({ page }) => {
await open(page)
await page.getByTestId("prompt-rail-content").focus()
await page.evaluate(() => {
document.body.dataset.escapes = "0"
document.addEventListener("keydown", (event) => {
if (event.key !== "Escape") return
document.body.dataset.escapes = String(Number(document.body.dataset.escapes) + 1)
})
})
await page.locator(".prompt-rail-tick").first().hover()
await page.clock.runFor(350)
await expect(page.locator(".prompt-rail-card")).toBeVisible()
await page.keyboard.press("Escape")
await page.clock.runFor(500)
await expect(page.locator(".prompt-rail-card")).toBeHidden()
await expect(page.locator("body")).toHaveAttribute("data-escapes", "0")
await page.keyboard.press("Escape")
await expect(page.locator("body")).toHaveAttribute("data-escapes", "1")
})
test("does not open during a drag or while scrolling over the rail", async ({ page }) => {
await open(page)
const tick = page.locator(".prompt-rail-tick").first()
await page.getByTestId("prompt-rail-content").hover()
await page.mouse.down()
await tick.hover()
await page.clock.runFor(500)
await expect(page.locator(".prompt-rail-card")).toBeHidden()
await page.mouse.up()
await page.getByTestId("prompt-rail-content").hover()
await tick.hover()
await page.mouse.wheel(0, -120)
await expect(page.getByTestId("prompt-rail-host")).toHaveAttribute("data-wheel", "-120")
await page.clock.runFor(500)
await expect(page.locator(".prompt-rail-card")).toBeHidden()
})
test("closes after keyboard focus leaves the navigator", async ({ page }) => {
await open(page)
const card = page.locator(".prompt-rail-card")
await page.locator(".prompt-rail-tick").first().focus()
await card.locator(".prompt-rail-row").first().focus()
await page.clock.runFor(200)
await expect(card).toBeVisible()
await page.getByTestId("prompt-rail-content").focus()
await page.clock.runFor(120)
await expect(card).toBeHidden()
})
test("retains the virtualized navigator and older-history navigation", async ({ page }) => {
await page.goto(`/iframe.html?id=chat--prompt-rail-many-prompts&viewMode=story&globals=${GLOBALS}`, {
waitUntil: "load",
})
const card = page.locator(".prompt-rail-card")
await page.locator(".prompt-rail-tick").first().focus()
await expect(card).toHaveAttribute("data-virtualized", "true")
await card.getByRole("button", { name: "First prompt", exact: true }).click()
await expect(page.locator(".message-list-turns")).toHaveAttribute("data-loaded-messages", "160")
await expect(card.locator('[data-prompt-index="0"]')).toBeVisible()
})
@@ -0,0 +1,45 @@
import { describe, expect, it } from "bun:test"
import { edge } from "../../webview-ui/src/sidebar-position"
const view = { screenX: 144, outerWidth: 1440, innerWidth: 299 }
describe("sidebar position", () => {
it.each([
[0, 192, "left"],
[149.5, 341.5, "left"],
[299, 491, "left"],
[0, 1285, "right"],
[149.5, 1434.5, "right"],
[299, 1584, "right"],
] as const)("resolves client %s at screen %s to the %s edge", (client, screen, side) => {
expect(edge({ clientX: client, screenX: screen }, view)).toBe(side)
})
it("uses the window origin on a monitor with negative coordinates", () => {
const host = { ...view, screenX: -1440 }
expect(edge({ clientX: 149.5, screenX: -1242.5 }, host)).toBe("left")
expect(edge({ clientX: 149.5, screenX: -149.5 }, host)).toBe("right")
})
it("keeps the outer edge when the sidebar is wider than half the window", () => {
const host = { screenX: 0, outerWidth: 1440, innerWidth: 1000 }
expect(edge({ clientX: 950, screenX: 998 }, host)).toBe("left")
expect(edge({ clientX: 50, screenX: 490 }, host)).toBe("right")
})
it("handles pointer coordinates from a zoomed webview", () => {
expect(edge({ clientX: 149.5703125, screenX: 1404.484375 }, view)).toBe("right")
expect(edge({ clientX: 149.5, screenX: 381 }, view)).toBe("left")
})
it("ignores unavailable or invalid geometry", () => {
const event = { clientX: 149.5, screenX: 341.5 }
expect(edge(event, { ...view, outerWidth: 0 })).toBeUndefined()
expect(edge(event, { ...view, innerWidth: 0 })).toBeUndefined()
expect(edge(event, { ...view, outerWidth: Number.NaN })).toBeUndefined()
expect(edge(event, { ...view, innerWidth: Number.POSITIVE_INFINITY })).toBeUndefined()
expect(edge({ ...event, screenX: -10000 }, view)).toBeUndefined()
expect(edge({ ...event, screenX: 10000 }, view)).toBeUndefined()
expect(edge({ ...event, clientX: Number.NaN }, view)).toBeUndefined()
})
})
@@ -26,6 +26,7 @@ import { Spinner } from "@kilocode/kilo-ui/spinner"
import { createAutoScroll } from "@kilocode/kilo-ui/hooks"
import { useSession } from "../../context/session"
import { useServer } from "../../context/server"
import { useVSCode } from "../../context/vscode"
import { useLanguage } from "../../context/language"
import { useI18n } from "@kilocode/kilo-ui/context/i18n"
import { useProvider } from "../../context/provider"
@@ -103,6 +104,7 @@ interface MessageListProps {
export const MessageList: Component<MessageListProps> = (props) => {
const session = useSession()
const server = useServer()
const vscode = useVSCode()
const language = useLanguage()
const provider = useProvider()
const i18n = useI18n()
@@ -1374,6 +1376,8 @@ export const MessageList: Component<MessageListProps> = (props) => {
</div>
<PromptRail
// Editor tabs and Agent Manager have no sidebar edge signal. Keep their rail on the physical right in RTL too.
side={vscode.sidebarSide() ?? "right"}
entries={entries}
items={items}
active={() => railActiveKey()}
@@ -1,22 +1,16 @@
/** @jsxImportSource solid-js */
/**
* PromptRail component
* Thin vertical summary rail on the left edge of the transcript. Hovering or
* focusing opens a bounded navigator for every loaded prompt; clicking jumps
* the virtualized transcript without mounting the intervening rows.
*/
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { Spinner } from "@kilocode/kilo-ui/spinner"
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
import { For, Show, createEffect, createMemo, createSignal, onCleanup, type Accessor } from "solid-js"
import { For, Show, createEffect, createMemo, createSignal, on, onCleanup, type Accessor } from "solid-js"
import { Portal } from "solid-js/web"
import { VList, type VListHandle } from "virtua/solid"
import { useLanguage } from "../../context/language"
import { RAIL_INSET, ROW_HEIGHT, TICK_MIN, TICK_STEP, type PromptRailEntry, type PromptRailItem } from "./prompt-rail"
interface PromptRailProps {
side: "left" | "right"
entries: Accessor<PromptRailEntry[]>
items: Accessor<PromptRailItem[]>
/** Row key of the item whose turn is currently at the top of the transcript. */
@@ -35,6 +29,7 @@ interface PromptRailProps {
seeking: Accessor<boolean>
}
const OPEN_DELAY = 350
const CLOSE_DELAY = 120
const EDGE = 12
const GAP = 8
@@ -47,11 +42,12 @@ export function PromptRail(props: PromptRailProps) {
const [open, setOpen] = createSignal(false)
const [hover, setHover] = createSignal<string>()
const [focused, setFocused] = createSignal<number>()
const [anchor, setAnchor] = createSignal<{ top: number; left: number; height: number }>()
const [anchor, setAnchor] = createSignal<{ top: number; edge: number; height: number }>()
let rail: HTMLElement | undefined
let card: HTMLDivElement | undefined
let list: VListHandle | undefined
let timer: ReturnType<typeof setTimeout> | undefined
let pending: ReturnType<typeof setTimeout> | undefined
let frame: number | undefined
let revealing = false
@@ -98,11 +94,16 @@ export function PromptRail(props: PromptRailProps) {
const center = rect.top + rect.height / 2 - height / 2
setAnchor({
top: max < min ? min : Math.min(Math.max(center, min), max),
left: rect.right + GAP,
edge: (props.side === "right" ? window.innerWidth - rect.left : rect.right) + GAP,
height: limit,
})
}
const cancelOpen = () => {
if (pending !== undefined) clearTimeout(pending)
pending = undefined
}
const cancelClose = () => {
if (timer !== undefined) clearTimeout(timer)
timer = undefined
@@ -135,9 +136,11 @@ export function PromptRail(props: PromptRailProps) {
const dragging = (event: MouseEvent) => event.buttons !== 0
const openCard = (index: number) => {
cancelOpen()
cancelClose()
const entry = entries()[index]
const item = entry && entryItem(entry)
if (!entry || entries().length < 2) return
const item = entryItem(entry)
setFocused(index)
setHover(item?.key)
place()
@@ -145,19 +148,62 @@ export function PromptRail(props: PromptRailProps) {
if (item) reveal(items().findIndex((candidate) => candidate.key === item.key))
}
const closeCard = () => {
const preview = (index: number, event: MouseEvent) => {
cancelOpen()
if (dragging(event)) return
cancelClose()
if (open()) return openCard(index)
const entry = entries()[index]
if (!entry) return
const key = entryItem(entry)?.key
pending = setTimeout(() => {
pending = undefined
const entry = entries()[index]
if (!entry || entryItem(entry)?.key !== key) return
openCard(index)
}, OPEN_DELAY)
}
const dismiss = () => {
cancelOpen()
cancelClose()
setOpen(false)
setHover(undefined)
}
const closeCard = () => {
cancelOpen()
cancelClose()
if (!open()) return
timer = setTimeout(() => {
timer = undefined
setOpen(false)
setHover(undefined)
}, CLOSE_DELAY)
}
onCleanup(cancelClose)
const escape = (event: KeyboardEvent) => {
if (event.key !== "Escape" || event.defaultPrevented) return
cancelOpen()
if (!open()) return
event.preventDefault()
event.stopPropagation()
dismiss()
}
window.addEventListener("keydown", escape, true)
onCleanup(() => {
cancelOpen()
cancelClose()
window.removeEventListener("keydown", escape, true)
if (frame !== undefined) cancelAnimationFrame(frame)
})
createEffect(on(() => props.side, cancelOpen, { defer: true }))
createEffect(() => {
if (entries().length < 2) dismiss()
})
// Resizing the panel moves the rail out from under an open card.
createEffect(() => {
if (!open()) return
@@ -168,11 +214,14 @@ export function PromptRail(props: PromptRailProps) {
// Re-place once the card is measurable, so rows that wrap differently than
// the estimate still end up centered on the ticks.
createEffect(() => {
if (!open() || !card) return
const frame = requestAnimationFrame(() => place())
onCleanup(() => cancelAnimationFrame(frame))
})
createEffect(
on([open, () => props.side], () => {
if (!open() || !card) return
place()
const frame = requestAnimationFrame(() => place())
onCleanup(() => cancelAnimationFrame(frame))
}),
)
let seeking = false
createEffect(() => {
@@ -190,9 +239,7 @@ export function PromptRail(props: PromptRailProps) {
const current = focused() ?? 0
if (event.key === "Escape") {
event.preventDefault()
cancelClose()
setOpen(false)
setHover(undefined)
dismiss()
return
}
if (event.key === "Enter" || event.key === " ") {
@@ -279,6 +326,7 @@ export function PromptRail(props: PromptRailProps) {
<nav
ref={rail}
class="prompt-rail"
data-side={props.side}
aria-label={language.t("session.prompts.navLabel")}
style={{ "--prompt-rail-step": `${step()}px` }}
onMouseLeave={closeCard}
@@ -288,6 +336,7 @@ export function PromptRail(props: PromptRailProps) {
}}
onKeyDown={onKeyDown}
onWheel={(event) => {
cancelOpen()
event.preventDefault()
props.onWheel(event.deltaY)
}}
@@ -305,12 +354,11 @@ export function PromptRail(props: PromptRailProps) {
data-queued={(entry.type === "prompt" && entry.item.queued) || undefined}
aria-label={entryLabel(entry)}
tabIndex={index() === (focused() ?? 0) ? 0 : -1}
onMouseEnter={(event) => {
if (dragging(event)) return
openCard(index())
}}
onMouseEnter={(event) => preview(index(), event)}
onMouseLeave={cancelOpen}
onFocus={() => openCard(index())}
onClick={() => {
cancelOpen()
if (entry.type === "prompt") props.onSelect(entry.item)
if (entry.type === "history") selectFirst()
if (entry.type === "overflow") openCard(index())
@@ -328,16 +376,24 @@ export function PromptRail(props: PromptRailProps) {
<div
ref={card}
class="prompt-rail-card"
data-side={props.side}
data-virtualized={virtualized() || undefined}
role="dialog"
aria-label={language.t("session.prompts.navLabel")}
style={{
top: `${position().top}px`,
left: `${position().left}px`,
left: props.side === "left" ? `${position().edge}px` : "auto",
right: props.side === "right" ? `${position().edge}px` : "auto",
"--prompt-rail-card-height": `${position().height}px`,
}}
onMouseEnter={cancelClose}
onMouseLeave={closeCard}
onFocusIn={cancelClose}
onFocusOut={(event) => {
if (!event.relatedTarget) return
if (card?.contains(event.relatedTarget as Node) || rail?.contains(event.relatedTarget as Node)) return
closeCard()
}}
onWheel={(event) => {
// A reveal placed the list here, so any wheel from now on is the
// user's. Scrolling up at the very top emits no scroll event, so
@@ -6,6 +6,7 @@
import { createContext, useContext, onCleanup, ParentComponent, createSignal } from "solid-js"
import type { VSCodeAPI, WebviewMessage, ExtensionMessage } from "../types/messages"
import { ClipboardProvider } from "@kilocode/kilo-ui/context/clipboard"
import { edge } from "../sidebar-position"
// Get the VS Code API (only available in webview context)
let vscodeApi: VSCodeAPI | undefined
@@ -34,6 +35,7 @@ interface VSCodeContextValue {
onMessage: (handler: (message: ExtensionMessage) => void) => () => void
getState: <T>() => T | undefined
setState: <T>(state: T) => void
sidebarSide: () => "left" | "right" | undefined
getModelSelectorExpanded: () => boolean
setModelSelectorExpanded: (value: boolean) => void
}
@@ -44,6 +46,23 @@ export const VSCodeProvider: ParentComponent = (props) => {
const api = getVSCodeAPI()
const handlers = new Set<(message: ExtensionMessage) => void>()
const copies = new Map<string, { resolve: () => void; reject: (err: Error) => void }>()
const initial = document.documentElement.dataset.sidebar
const saved = (api.getState() as { sidebarSide?: unknown } | undefined)?.sidebarSide
const [side, setSide] = createSignal<"left" | "right" | undefined>(
initial === "left" || initial === "right" ? (saved === "left" || saved === "right" ? saved : initial) : undefined,
)
const position = (event: PointerEvent) => {
const next = edge(event, window)
if (!next || next === side()) return
setSide(next)
api.setState({ ...(api.getState() as Record<string, unknown> | undefined), sidebarSide: next })
}
if (side()) {
window.addEventListener("pointerover", position, true)
window.addEventListener("pointermove", position, true)
}
// Model-selector expand/collapse preference. Stored in extension globalState
// so it is shared across webviews (sidebar + agent-manager panel); a local
@@ -81,6 +100,8 @@ export const VSCodeProvider: ParentComponent = (props) => {
window.removeEventListener("message", messageListener)
window.removeEventListener("focus", reportFocus)
window.removeEventListener("blur", reportFocus)
window.removeEventListener("pointerover", position, true)
window.removeEventListener("pointermove", position, true)
handlers.clear()
copies.clear()
})
@@ -95,6 +116,7 @@ export const VSCodeProvider: ParentComponent = (props) => {
},
getState: <T,>() => api.getState() as T | undefined,
setState: <T,>(state: T) => api.setState(state),
sidebarSide: side,
getModelSelectorExpanded: expanded,
setModelSelectorExpanded: (value: boolean) => {
setExpanded(value)
@@ -0,0 +1,9 @@
export function edge(
event: Pick<MouseEvent, "screenX" | "clientX">,
view: Pick<Window, "screenX" | "outerWidth" | "innerWidth">,
): "left" | "right" | undefined {
const center = event.screenX - view.screenX - event.clientX + view.innerWidth / 2
if (!Number.isFinite(center) || !Number.isFinite(view.outerWidth)) return
if (view.outerWidth <= 0 || view.innerWidth <= 0 || center < 0 || center > view.outerWidth) return
return center < view.outerWidth / 2 ? "left" : "right"
}
@@ -18,6 +18,10 @@ import { TaskUsage } from "../components/chat/TaskUsage"
import { QuestionDock } from "../components/chat/QuestionDock"
import { SuggestBar } from "../components/chat/SuggestBar"
import { MessageList } from "../components/chat/MessageList"
import { PromptRail } from "../components/chat/PromptRail"
import { promptItems, railEntries } from "../components/chat/prompt-rail"
import { messageTurns } from "../context/session-queue"
import { transcriptRows } from "../context/transcript-rows"
import { VscodeUserMessage } from "../components/chat/VscodeUserMessage"
import { SidebarTopBar } from "../components/chat/SidebarTopBar"
import { TurnOutcome } from "../components/shared/TurnOutcome"
@@ -565,7 +569,6 @@ export const ChatViewReadable420: Story = {
}
// ---------------------------------------------------------------------------
// PromptRail — the left-edge tick rail and its hover card
// Several turns so the rail and card are populated: a long prompt, a short
// low-signal follow-up, a tool-only answer (empty preview), and a queued one.
// ---------------------------------------------------------------------------
@@ -668,6 +671,55 @@ export const PromptRailSidebar: Story = {
render: () => renderRailChat("busy"),
}
const rail = (side: "left" | "right") => {
const items = promptItems(transcriptRows(messageTurns(railMessages), (id) => railParts[id] ?? []))
const [active, setActive] = createSignal<string | undefined>(items[0]?.key)
const [wheel, setWheel] = createSignal(0)
return (
<StoryProviders noPadding>
<div
class="message-list-container"
data-testid="prompt-rail-host"
data-selected={active()}
data-wheel={wheel()}
style={{ height: "100vh" }}
>
<div class="message-list">
<p data-testid="prompt-rail-content" tabIndex={0}>
{items.find((item) => item.key === active())?.prompt}
</p>
</div>
<PromptRail
side={side}
entries={() => railEntries(items, items.length)}
items={() => items}
active={active}
onSelect={(item) => setActive(item.key)}
onFirst={() => setActive(items[0]?.key)}
onLatest={() => setActive(items.at(-1)?.key)}
onLoadOlder={() => undefined}
onWheel={(delta) => setWheel(delta)}
height={() => window.innerHeight}
hasOlder={() => false}
loadingOlder={() => false}
prepending={() => false}
seeking={() => false}
/>
</div>
</StoryProviders>
)
}
export const PromptRailLeft: Story = {
name: "PromptRail - left outer edge",
render: () => rail("left"),
}
export const PromptRailRight: Story = {
name: "PromptRail - right outer edge",
render: () => rail("right"),
}
// Long session: more prompts than fit the transcript height, so the rail and
// the card both cap to the newest ones that fit.
const manyTurns = Array.from({ length: 80 }, (_, i) =>
@@ -747,8 +799,13 @@ export const MessageListLayoutCorrection: Story = {
name: "MessageList - follow after layout correction",
render: () => {
const [output, setOutput] = createSignal("Initial streamed response.")
const [status, setStatus] = createSignal<"idle" | "busy">("busy")
const session = {
...mockSessionValue({ id: SESSION_ID, status: "busy" }),
status,
statusInfo: () => ({ type: status() }),
statusText: () => (status() === "busy" ? "Thinking…" : undefined),
busySince: () => (status() === "busy" ? Date.now() - 2000 : undefined),
messages: () => correctionMessages,
userMessages: () => correctionMessages.filter((msg) => msg.role === "user"),
getParts: (id: string) => {
@@ -769,6 +826,8 @@ export const MessageListLayoutCorrection: Story = {
position: fixed;
inset: 8px 8px auto auto;
z-index: 10;
display: flex;
gap: 8px;
}
`}</style>
<div class="auto-scroll-correction-controls">
@@ -779,6 +838,13 @@ export const MessageListLayoutCorrection: Story = {
>
Append stream
</button>
<button
type="button"
data-testid="toggle-status"
onClick={() => setStatus((value) => (value === "busy" ? "idle" : "busy"))}
>
Toggle status
</button>
</div>
<ChatView />
</div>
@@ -18,7 +18,7 @@
editor tab the lane is centered, so a lane-hugging rail would sit right
next to the prose the pointer travels over and open on the way past. The
far edge is somewhere the pointer only goes deliberately. */
inset-inline-start: var(--prompt-rail-edge);
left: var(--prompt-rail-edge);
top: 0;
bottom: 0;
width: var(--prompt-rail-width);
@@ -35,7 +35,20 @@
transition: opacity 0.25s var(--prompt-rail-ease);
}
.message-list-container:hover .prompt-rail,
.prompt-rail[data-side="right"] {
left: auto;
right: var(--prompt-rail-edge);
}
.prompt-rail[data-side="right"] .prompt-rail-tick {
justify-content: right;
}
.prompt-rail[data-side="right"] .prompt-rail-tick-line {
transform-origin: right center;
}
.prompt-rail:hover,
.prompt-rail:focus-within {
opacity: 1;
}
@@ -44,7 +57,7 @@
pointer-events: auto;
display: flex;
align-items: center;
justify-content: flex-start;
justify-content: left;
height: var(--prompt-rail-step);
min-height: 4px;
width: 100%;
@@ -164,6 +177,11 @@
transform-origin: left center;
}
.prompt-rail-card[data-side="right"] {
--prompt-rail-enter: 6px;
transform-origin: right center;
}
.prompt-rail-card[data-virtualized] {
display: flex;
flex-direction: column;
@@ -246,7 +264,7 @@
@keyframes prompt-rail-in {
from {
opacity: 0;
transform: translateX(-6px) scale(0.98);
transform: translateX(var(--prompt-rail-enter, -6px)) scale(0.98);
}
to {
opacity: 1;