Merge pull request #13499 from Kilo-Org/improve-prompt-navigator-placement

fix(vscode): reduce prompt navigator interruptions
This commit is contained in:
Marius
2026-08-27 11:31:25 +02:00
committed by GitHub
13 changed files with 438 additions and 34 deletions
+5
View File
@@ -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.
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2f39a67f1abfc391d9784d0c0dec6abb33fc8940ce6c6bed07cc47f2ebcff0f5
size 11599
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ee3282e45fbd929ec6f39c56dd9db08d6b5952b44a77245173d4bcf0eb58791a
size 11601
+8 -2
View File
@@ -751,7 +751,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
localResourceRoots: [this.extensionUri],
}
webviewView.webview.html = this._getHtmlForWebview(webviewView.webview)
webviewView.webview.html = this._getHtmlForWebview(webviewView.webview, true)
this.setupWebviewMessageHandler(webviewView.webview)
this.setSidebarVisible(webviewView.visible)
@@ -5319,8 +5319,14 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
return resolveProjectDirectory(this.projectDirectory, () => this.getWorkspaceDirectory(sessionId))
}
private _getHtmlForWebview(webview: vscode.Webview): string {
private _getHtmlForWebview(webview: vscode.Webview, sidebar = false): string {
return buildWebviewHtml(webview, {
// The rail follows the physical workbench edge. RTL text direction must not move it between chat and code.
sidebar: sidebar
? vscode.workspace.getConfiguration("workbench").get("sideBar.location") === "right"
? "right"
: "left"
: undefined,
scriptUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "webview.js")),
styleUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "webview.css")),
iconsBaseUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "assets", "icons")),
+2 -1
View File
@@ -66,6 +66,7 @@ export function buildWebviewHtml(
topBar?: boolean
topBarSurface?: string
agentManagerSettings?: boolean
sidebar?: "left" | "right"
},
): string {
const nonce = getNonce()
@@ -73,7 +74,7 @@ export function buildWebviewHtml(
const markdownWorkerUri = opts.workerUri.toString().replace(/shiki-worker\.js$/, "markdown-shiki-worker.js")
return `<!DOCTYPE html>
<html lang="en" data-theme="kilo-vscode">
<html lang="en" data-theme="kilo-vscode" data-sidebar="${opts.sidebar ?? ""}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -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) =>
@@ -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;