diff --git a/.changeset/calm-prompt-navigator.md b/.changeset/calm-prompt-navigator.md new file mode 100644 index 0000000000..0bcefe67f2 --- /dev/null +++ b/.changeset/calm-prompt-navigator.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Place the prompt navigator on the outer sidebar edge and delay hover previews to avoid accidental popups. Keep the navigator on the right in Agent Manager and editor tabs. diff --git a/.changeset/fix-streaming-scroll-intent.md b/.changeset/fix-streaming-scroll-intent.md new file mode 100644 index 0000000000..82b6d09b94 --- /dev/null +++ b/.changeset/fix-streaming-scroll-intent.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Keep chat scroll position during streaming, including small upward gestures, direction changes, and scrolling over message controls. diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-left-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-left-chromium-linux.png new file mode 100644 index 0000000000..e8a68e4d88 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-left-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2f39a67f1abfc391d9784d0c0dec6abb33fc8940ce6c6bed07cc47f2ebcff0f5 +size 11599 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-right-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-right-chromium-linux.png new file mode 100644 index 0000000000..a273054233 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-right-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ee3282e45fbd929ec6f39c56dd9db08d6b5952b44a77245173d4bcf0eb58791a +size 11601 diff --git a/packages/kilo-ui/src/hooks/create-auto-scroll.test.tsx b/packages/kilo-ui/src/hooks/create-auto-scroll.test.tsx index fb72b94652..a0a8e694ee 100644 --- a/packages/kilo-ui/src/hooks/create-auto-scroll.test.tsx +++ b/packages/kilo-ui/src/hooks/create-auto-scroll.test.tsx @@ -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() private listeners = new Map() - 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) diff --git a/packages/kilo-ui/src/hooks/create-auto-scroll.tsx b/packages/kilo-ui/src/hooks/create-auto-scroll.tsx index 64e059df8d..e294655355 100644 --- a/packages/kilo-ui/src/hooks/create-auto-scroll.tsx +++ b/packages/kilo-ui/src/hooks/create-auto-scroll.tsx @@ -22,6 +22,7 @@ export function createAutoScroll(options: AutoScrollOptions) { // --------------------------------------------------------------------------- let scroll: HTMLElement | undefined + let top = 0 let settling = false let settleTimer: ReturnType | 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 diff --git a/packages/kilo-ui/src/hooks/scroll-user-activity.ts b/packages/kilo-ui/src/hooks/scroll-user-activity.ts index a0bcdf0c17..f82d78e77d 100644 --- a/packages/kilo-ui/src/hooks/scroll-user-activity.ts +++ b/packages/kilo-ui/src/hooks/scroll-user-activity.ts @@ -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, } } diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index db5c717dca..1c1450bebc 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -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")), diff --git a/packages/kilo-vscode/src/utils.ts b/packages/kilo-vscode/src/utils.ts index ce17c8d754..2d952bbdcc 100644 --- a/packages/kilo-vscode/src/utils.ts +++ b/packages/kilo-vscode/src/utils.ts @@ -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 ` - + diff --git a/packages/kilo-vscode/tests/chat-auto-scroll.spec.ts b/packages/kilo-vscode/tests/chat-auto-scroll.spec.ts index cef78a90d8..8a7f6f9197 100644 --- a/packages/kilo-vscode/tests/chat-auto-scroll.spec.ts +++ b/packages/kilo-vscode/tests/chat-auto-scroll.spec.ts @@ -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() +}) diff --git a/packages/kilo-vscode/tests/prompt-rail.spec.ts b/packages/kilo-vscode/tests/prompt-rail.spec.ts new file mode 100644 index 0000000000..8a924460ff --- /dev/null +++ b/packages/kilo-vscode/tests/prompt-rail.spec.ts @@ -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() +}) diff --git a/packages/kilo-vscode/tests/unit/sidebar-position.test.ts b/packages/kilo-vscode/tests/unit/sidebar-position.test.ts new file mode 100644 index 0000000000..21ea1a5930 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/sidebar-position.test.ts @@ -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() + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx index 820f632fba..0e6a779897 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx @@ -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 = (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 = (props) => { railActiveKey()} diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptRail.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptRail.tsx index 0e487aa850..1ca5caace0 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptRail.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptRail.tsx @@ -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 items: Accessor /** Row key of the item whose turn is currently at the top of the transcript. */ @@ -35,6 +29,7 @@ interface PromptRailProps { seeking: Accessor } +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() const [focused, setFocused] = createSignal() - 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 | undefined + let pending: ReturnType | 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) {