mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
Merge branch 'main' into dependabot-babel-core
This commit is contained in:
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2f39a67f1abfc391d9784d0c0dec6abb33fc8940ce6c6bed07cc47f2ebcff0f5
|
||||
size 11599
|
||||
+3
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user