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-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 00000000000..e8a68e4d88c --- /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 00000000000..a2730542332 --- /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-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index db5c717dca1..1c1450bebc9 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 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..0e6a779897d 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