From 8c0a570222fcc51e23041c63f69db7118d5f4c7b Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 27 Aug 2026 10:17:35 +0200 Subject: [PATCH 1/3] fix(vscode): reduce prompt navigator interruptions --- .changeset/calm-prompt-navigator.md | 5 + packages/kilo-vscode/src/KiloProvider.ts | 9 +- packages/kilo-vscode/src/utils.ts | 3 +- .../kilo-vscode/tests/prompt-rail.spec.ts | 180 ++++++++++++++++++ .../tests/unit/sidebar-position.test.ts | 45 +++++ .../src/components/chat/MessageList.tsx | 3 + .../src/components/chat/PromptRail.tsx | 106 ++++++++--- .../webview-ui/src/context/vscode.tsx | 22 +++ .../webview-ui/src/sidebar-position.ts | 9 + .../webview-ui/src/stories/chat.stories.tsx | 54 +++++- .../webview-ui/src/styles/prompt-rail.css | 26 ++- 11 files changed, 429 insertions(+), 33 deletions(-) create mode 100644 .changeset/calm-prompt-navigator.md create mode 100644 packages/kilo-vscode/tests/prompt-rail.spec.ts create mode 100644 packages/kilo-vscode/tests/unit/sidebar-position.test.ts create mode 100644 packages/kilo-vscode/webview-ui/src/sidebar-position.ts diff --git a/.changeset/calm-prompt-navigator.md b/.changeset/calm-prompt-navigator.md new file mode 100644 index 00000000000..0bcefe67f27 --- /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/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index db5c717dca1..c9c8bbeaa69 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,13 @@ 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, { + 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 ce17c8d7549..2d952bbdccf 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/prompt-rail.spec.ts b/packages/kilo-vscode/tests/prompt-rail.spec.ts new file mode 100644 index 00000000000..8a924460ffe --- /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 00000000000..21ea1a59306 --- /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 820f632fbab..b25a4e381d1 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,7 @@ 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 0e487aa850a..7ebb57ef0ec 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 + GAP : 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 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) {