fix(vscode): improve long-session prompt navigation (#12656)

* fix(vscode): improve long-session prompt navigation

* fix(vscode): translate prompt navigation in Persian

* fix(vscode): complete deferred prompt jumps

* chore: update kilo-vscode visual regression baselines

---------

Co-authored-by: kilo-maintainer[bot] <kilo-maintainer[bot]@users.noreply.github.com>
This commit is contained in:
Marius
2026-07-30 10:48:35 +02:00
committed by GitHub
parent 3b8bd23e1b
commit f0d2c8ffc8
33 changed files with 665 additions and 97 deletions
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Navigate long conversations from a compact prompt rail that loads earlier history as you scroll.
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:51adb9e31ce0bc82981b0f20f895ce4ede3f92828546115f929942ef93bf0812
size 11204
oid sha256:d7fc23fcb7adf483c0b771ef601b23cb365dc7f40033b00fce703b35910aa4fc
size 27159
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c5658ed9e5266311c4239b7cd470d2f772d1cc0b3fd6c6b00337d4f3141a3a4d
size 11950
oid sha256:d11f4004ed3170647d385c14df077d235f5bb9bd6e5dec07557ee2014d553233
size 27302
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:095ed97dc46498be6fd75483b24357ac0740b6f2c1544f6c103a1dac360e1a7e
size 11202
oid sha256:f8bd56ba87d0c2bbef8a2325ab2e9f0e956c89e2602ff8ca7415d5fb7c9fa1f3
size 29709
@@ -2,7 +2,13 @@ import { describe, expect, it } from "bun:test"
import { messageTurns } from "../../webview-ui/src/context/session-queue"
import { transcriptRows } from "../../webview-ui/src/context/transcript-rows"
import type { Message, Part, TextPart } from "../../webview-ui/src/types/messages"
import { capacity, previewText, promptItems, railItems } from "../../webview-ui/src/components/chat/prompt-rail"
import {
capacity,
historyAction,
previewText,
promptItems,
railEntries,
} from "../../webview-ui/src/components/chat/prompt-rail"
const base = {
sessionID: "session",
@@ -157,18 +163,38 @@ describe("promptItems", () => {
})
describe("capacity", () => {
it("counts how many worst-case rows fit the transcript height", () => {
expect(capacity(24 + 76 * 5)).toBe(5)
expect(capacity(100)).toBe(1)
it("counts how many ticks fit the transcript height", () => {
expect(capacity(24 + 7 * 5)).toBe(5)
expect(capacity(31)).toBe(1)
})
it("fits far more ticks than the navigator lists rows", () => {
// A tick is a hairline, so a sidebar-height transcript holds a whole
// session's prompts rather than the handful of card rows that fit.
expect(capacity(724)).toBe(100)
})
it("returns nothing usable for unmeasured or tiny transcripts", () => {
expect(capacity(0)).toBeLessThan(1)
expect(capacity(99)).toBeLessThan(1)
expect(capacity(30)).toBeLessThan(1)
})
})
describe("railItems", () => {
describe("historyAction", () => {
it("loads the next page only after the previous page made progress", () => {
expect(historyAction(80, 160, true)).toBe("load")
})
it("jumps after the final page", () => {
expect(historyAction(160, 200, false)).toBe("jump")
})
it("stops instead of retrying a page that made no progress", () => {
expect(historyAction(160, 160, true)).toBe("stop")
})
})
describe("railEntries", () => {
const items = Array.from({ length: 5 }, (_, i) => ({
key: `k${i}`,
turn: `t${i}`,
@@ -178,15 +204,36 @@ describe("railItems", () => {
}))
it("passes through when everything fits", () => {
expect(railItems(items, 5)).toEqual(items)
expect(railItems(items, 10)).toEqual(items)
expect(railEntries(items, 5)).toEqual(items.map((item, index) => ({ type: "prompt", item, index })))
expect(railEntries(items, 10)).toEqual(items.map((item, index) => ({ type: "prompt", item, index })))
})
it("keeps the newest items when capacity is smaller", () => {
expect(railItems(items, 2)).toEqual(items.slice(-2))
it("keeps the first and latest prompts at minimal capacity", () => {
expect(railEntries(items, 2)).toEqual([
{ type: "prompt", item: items[0], index: 0 },
{ type: "prompt", item: items[4], index: 4 },
])
})
it("summarizes hidden loaded prompts between the first and recent prompts", () => {
expect(railEntries(items, 4)).toEqual([
{ type: "prompt", item: items[0], index: 0 },
{ type: "overflow", count: 2, index: 1 },
{ type: "prompt", item: items[3], index: 3 },
{ type: "prompt", item: items[4], index: 4 },
])
})
it("reserves the first entry for unloaded history", () => {
expect(railEntries(items, 4, true)).toEqual([
{ type: "history" },
{ type: "overflow", count: 3, index: 0 },
{ type: "prompt", item: items[3], index: 3 },
{ type: "prompt", item: items[4], index: 4 },
])
})
it("returns nothing at zero capacity", () => {
expect(railItems(items, 0)).toEqual([])
expect(railEntries(items, 0)).toEqual([])
})
})
@@ -71,7 +71,7 @@ import {
type TranscriptRow,
} from "../../context/transcript-rows"
import { PromptRail } from "./PromptRail"
import { capacity, promptItems, railItems, type PromptRailItem } from "./prompt-rail"
import { capacity, historyAction, promptItems, railEntries, type PromptRailItem } from "./prompt-rail"
import { onTimelineHighlight, type TimelineHighlight } from "../../utils/timeline/highlight"
import { useTranscriptSearch, type SearchMatch } from "../../context/transcript-search"
import { applyTranscriptHighlights, clearTranscriptHighlights } from "./transcript-search-highlight"
@@ -909,8 +909,8 @@ export const MessageList: Component<MessageListProps> = (props) => {
// entirely to the precise per-occurrence check in paintHighlights,
// which only scrolls when the exact match actually needs it.
if (!mounted) {
const index = keys().indexOf(match.key)
if (index >= 0) {
const index = indexes().get(match.key)
if (index !== undefined) {
virtualizer()?.scrollToIndex(index, { align: "center" })
}
}
@@ -946,23 +946,65 @@ export const MessageList: Component<MessageListProps> = (props) => {
const tail = createMemo(() => partition().direct.map((row) => row.key))
const lookup = createMemo(() => new Map(partition().direct.map((row) => [row.key, row])))
const keys = createMemo(() => partition().virtual.map((row) => row.key))
const indexes = createMemo(() => new Map(keys().map((key, index) => [key, index])))
const fingerprint = createMemo(() => rowFingerprint(keys()))
const [pending, setPending] = createSignal<{ sid: string; key: string }>()
// Scrolls the transcript to a row by key. Virtualized rows jump through
// the virtualizer; direct/live/queued rows are mounted, so they use
// scrollIntoView. Pauses auto-follow first so the jump isn't snapped back.
const jump = (key: string) => {
autoScroll.pause()
const index = keys().indexOf(key)
if (index >= 0) {
virtualizer()?.scrollToIndex(index, { align: "start" })
const index = indexes().get(key)
if (index !== undefined) {
const handle = virtualizer()
if (handle) {
setPending(undefined)
handle.scrollToIndex(index, { align: "start" })
return
}
const sid = session.currentSessionID()
if (sid) setPending({ sid, key })
return
}
const el = scrollEl()
const target = el?.querySelector<HTMLElement>(`[data-row-key="${CSS.escape(key)}"]`)
target?.scrollIntoView({ block: "start" })
if (target) {
setPending(undefined)
target.scrollIntoView({ block: "start" })
return
}
const sid = session.currentSessionID()
if (sid) setPending({ sid, key })
}
// Keep unresolved targets by stable row key. Virtual rows resolve once
// Virtua installs its handle; direct/live rows resolve once Solid mounts
// their DOM node.
createEffect(() => {
const target = pending()
if (!target) return
if (target.sid !== session.currentSessionID()) {
setPending(undefined)
return
}
const index = indexes().get(target.key)
const handle = virtualizer()
if (index !== undefined && handle) {
setPending(undefined)
autoScroll.pause()
handle.scrollToIndex(index, { align: "start" })
return
}
const el = scrollEl()
const row = el?.querySelector<HTMLElement>(`[data-row-key="${CSS.escape(target.key)}"]`)
if (!row) return
setPending(undefined)
autoScroll.pause()
row.scrollIntoView({ block: "start" })
})
// Clicking a bar in the task timeline scrolls the transcript to that message.
// Jumps land instantly (no smooth animation): while pinned at the bottom, a
// smooth scroll's initial frames sit within createAutoScroll's near-bottom
@@ -985,12 +1027,67 @@ export const MessageList: Component<MessageListProps> = (props) => {
const items = createMemo(() => promptItems(rows()))
// Until the transcript is measured there is no height to cap against, and
// rendering every prompt would spill ticks past the rail on long sessions.
const shown = createMemo(() => railItems(items(), capacity(height())))
const entries = createMemo(() => railEntries(items(), capacity(height()), session.hasOlderMessages()))
const [activeTurn, setActiveTurn] = createSignal<string>()
const railActiveKey = createMemo(() => shown().find((item) => item.turn === activeTurn())?.key)
const railActiveKey = createMemo(() => items().find((item) => item.turn === activeTurn())?.key)
const [seek, setSeek] = createSignal<{ sid: string; count: number }>()
let paging = false
const first = () => {
const item = items()[0]
if (!session.hasOlderMessages()) {
if (item) jump(item.key)
return
}
const sid = session.currentSessionID()
if (!sid || session.loadingOlderMessages()) return
setSeek({ sid, count: session.messages().length })
if (!session.loadOlderMessages()) setSeek(undefined)
}
// Loading the first prompt is deliberate and progressive: each completed
// prepend advances the existing page cursor, while hover/open remains free
// of network and full-history work. Stop if a request makes no progress so
// backend failures cannot turn into a retry loop.
createEffect(() => {
const loading = session.loadingOlderMessages()
const target = seek()
if (!target) {
paging = loading
return
}
if (target.sid !== session.currentSessionID()) {
paging = false
setSeek(undefined)
return
}
if (loading) {
paging = true
return
}
if (!paging) return
paging = false
const count = session.messages().length
const action = historyAction(target.count, count, session.hasOlderMessages())
if (action === "stop") {
const item = items()[0]
setSeek(undefined)
if (item) jump(item.key)
return
}
if (action === "load") {
setSeek({ sid: target.sid, count })
if (!session.loadOlderMessages()) setSeek(undefined)
return
}
const item = items()[0]
setSeek(undefined)
if (item) jump(item.key)
})
const trackActive = () => {
const list = shown()
const list = items()
if (list.length === 0) return setActiveTurn(undefined)
const handle = virtualizer()
const offset = handle?.scrollOffset
@@ -998,6 +1095,11 @@ export const MessageList: Component<MessageListProps> = (props) => {
const row = partition().virtual[handle.findItemIndex(offset)]
if (row) return setActiveTurn(row.turn)
}
const el = scrollEl()
if (handle && el && el.scrollHeight > el.clientHeight + 1) {
const row = partition().virtual[0]
if (row) return setActiveTurn(row.turn)
}
setActiveTurn(list.at(-1)?.turn)
}
let activeFrame: number | undefined
@@ -1014,7 +1116,7 @@ export const MessageList: Component<MessageListProps> = (props) => {
// Re-derive the active turn whenever the transcript changes so the rail
// reflects a newly started turn even before any scrolling happens.
createEffect(() => {
shown()
items()
partition()
scheduleActive()
})
@@ -1263,14 +1365,25 @@ export const MessageList: Component<MessageListProps> = (props) => {
</div>
<PromptRail
items={shown}
entries={entries}
items={items}
active={() => railActiveKey()}
onSelect={(item: PromptRailItem) => jump(item.key)}
onFirst={first}
onLatest={() => {
const item = items().at(-1)
if (item) jump(item.key)
}}
onLoadOlder={() => session.loadOlderMessages()}
onWheel={(deltaY: number) => {
const el = scrollEl()
if (el) el.scrollTop += deltaY
}}
height={height}
hasOlder={session.hasOlderMessages}
loadingOlder={session.loadingOlderMessages}
prepending={() => session.messageMutation() === "prepend"}
seeking={() => Boolean(seek())}
/>
<Show when={autoScroll.userScrolled()}>
@@ -2,50 +2,84 @@
/**
* PromptRail component
* Thin vertical tick rail on the left edge of the transcript, one tick per
* user prompt. Hovering/focusing the rail opens a floating card listing the
* prompts with a short answer preview each; clicking jumps the transcript.
* 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 { Portal } from "solid-js/web"
import { VList, type VListHandle } from "virtua/solid"
import { useLanguage } from "../../context/language"
import { RAIL_INSET, ROW_HEIGHT, type PromptRailItem } from "./prompt-rail"
import { RAIL_INSET, ROW_HEIGHT, TICK_MIN, TICK_STEP, type PromptRailEntry, type PromptRailItem } from "./prompt-rail"
interface PromptRailProps {
entries: Accessor<PromptRailEntry[]>
items: Accessor<PromptRailItem[]>
/** Row key of the item whose turn is currently at the top of the transcript. */
active: Accessor<string | undefined>
onSelect: (item: PromptRailItem) => void
onFirst: () => void
onLatest: () => void
onLoadOlder: () => void
/** Forwards wheel events so scrolling over a tick scrolls the transcript. */
onWheel: (deltaY: number) => void
/** Transcript height, used to spread the ticks. */
height: Accessor<number>
hasOlder: Accessor<boolean>
loadingOlder: Accessor<boolean>
prepending: Accessor<boolean>
seeking: Accessor<boolean>
}
const CLOSE_DELAY = 120
const TICK_STEP = 14
const EDGE = 12
const GAP = 8
const VIRTUAL_LIMIT = 30
const CARD_CHROME = 44
const NEAR_TOP = 200
export function PromptRail(props: PromptRailProps) {
const language = useLanguage()
const [open, setOpen] = createSignal(false)
const [hover, setHover] = createSignal<number>()
const [anchor, setAnchor] = createSignal<{ top: number; left: number }>()
const [hover, setHover] = createSignal<string>()
const [focused, setFocused] = createSignal<number>()
const [anchor, setAnchor] = createSignal<{ top: number; left: number; height: number }>()
let rail: HTMLElement | undefined
let card: HTMLDivElement | undefined
let list: VListHandle | undefined
let timer: ReturnType<typeof setTimeout> | undefined
let frame: number | undefined
let revealing = false
const items = createMemo(() => props.items())
const entries = createMemo(() => props.entries())
const virtualized = createMemo(() => items().length > VIRTUAL_LIMIT)
// Ticks are spread over the available height, tightening as prompts pile up
// but never growing past their natural step.
// but never growing past their natural step nor packing tighter than a tick
// can still be aimed at.
const step = createMemo(() => {
const count = items().length
const count = entries().length
if (count === 0) return TICK_STEP
return Math.min(TICK_STEP, Math.floor((props.height() - RAIL_INSET) / count))
return Math.max(TICK_MIN, Math.min(TICK_STEP, Math.floor((props.height() - RAIL_INSET) / count)))
})
// Reaching the top of the navigator pages older history in, the same way the
// transcript itself loads earlier messages when scrolled near its top. Opening
// the card scrolls the hovered prompt into view, which would otherwise look
// like a scroll to the top and fetch on hover, so programmatic reveals are
// excluded and only scrolling the user drove pages.
const offset = () => (virtualized() ? (list?.scrollOffset ?? 0) : (card?.scrollTop ?? 0))
const page = (value: number) => {
if (revealing || value > NEAR_TOP) return
if (!props.hasOlder() || props.loadingOlder() || props.seeking()) return
props.onLoadOlder()
}
// Centers the card on the tick group so each row sits beside its own tick,
// then keeps it inside the transcript and the viewport. The rail spans the
// transcript exactly (top/bottom 0), so its own rect doubles as those bounds
@@ -55,13 +89,17 @@ export function PromptRail(props: PromptRailProps) {
const place = () => {
if (!rail) return
const rect = rail.getBoundingClientRect()
const height = card?.offsetHeight ?? Math.min(items().length * ROW_HEIGHT + EDGE, rect.height)
const limit = Math.max(0, Math.min(window.innerHeight - EDGE * 2, rect.height - 8))
if (limit === 0) return
const estimate = Math.min(items().length * ROW_HEIGHT + CARD_CHROME, limit)
const height = virtualized() ? limit : (card?.offsetHeight ?? estimate)
const min = Math.max(EDGE, rect.top + 4)
const max = Math.min(window.innerHeight - EDGE, rect.bottom - 4) - height
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,
height: limit,
})
}
@@ -70,11 +108,35 @@ export function PromptRail(props: PromptRailProps) {
timer = undefined
}
const reveal = (index: number) => {
if (frame !== undefined) cancelAnimationFrame(frame)
revealing = true
frame = requestAnimationFrame(() => {
frame = undefined
if (virtualized()) {
list?.scrollToIndex(index, { align: "center" })
return
}
const row = card?.querySelector<HTMLElement>(`[data-prompt-index="${index}"]`)
if (!row || !card) return
card.scrollTop = Math.max(0, row.offsetTop - card.clientHeight / 2 + row.offsetHeight / 2)
})
}
const entryItem = (entry: PromptRailEntry) => {
if (entry.type === "prompt") return entry.item
return items()[entry.type === "overflow" ? entry.index : 0]
}
const openCard = (index: number) => {
cancelClose()
setHover(index)
const entry = entries()[index]
const item = entry && entryItem(entry)
setFocused(index)
setHover(item?.key)
place()
setOpen(true)
if (item) reveal(items().findIndex((candidate) => candidate.key === item.key))
}
const closeCard = () => {
@@ -86,6 +148,9 @@ export function PromptRail(props: PromptRailProps) {
}
onCleanup(cancelClose)
onCleanup(() => {
if (frame !== undefined) cancelAnimationFrame(frame)
})
// Resizing the panel moves the rail out from under an open card.
createEffect(() => {
@@ -103,9 +168,20 @@ export function PromptRail(props: PromptRailProps) {
onCleanup(() => cancelAnimationFrame(frame))
})
let seeking = false
createEffect(() => {
const next = props.seeking()
if (seeking && !next && !props.hasOlder()) {
const item = items()[0]
setHover(item?.key)
if (item) reveal(0)
}
seeking = next
})
const onKeyDown = (event: KeyboardEvent) => {
const list = items()
const current = hover() ?? 0
const values = entries()
const current = focused() ?? 0
if (event.key === "Escape") {
event.preventDefault()
cancelClose()
@@ -115,19 +191,22 @@ export function PromptRail(props: PromptRailProps) {
}
if (event.key === "Enter" || event.key === " ") {
event.preventDefault()
const item = list[current]
if (item) props.onSelect(item)
const entry = values[current]
if (!entry) return
if (entry.type === "prompt") props.onSelect(entry.item)
if (entry.type === "history") props.onFirst()
if (entry.type === "overflow") openCard(current)
return
}
const next =
event.key === "ArrowDown"
? Math.min(list.length - 1, current + 1)
? Math.min(values.length - 1, current + 1)
: event.key === "ArrowUp"
? Math.max(0, current - 1)
: event.key === "Home"
? 0
: event.key === "End"
? list.length - 1
? values.length - 1
: undefined
if (next === undefined) return
event.preventDefault()
@@ -139,8 +218,58 @@ export function PromptRail(props: PromptRailProps) {
const label = (item: PromptRailItem, index: number) =>
language.t("session.prompts.tick", { index: index + 1, total: items().length, prompt: item.prompt })
const entryLabel = (entry: PromptRailEntry) => {
if (entry.type === "prompt") return label(entry.item, entry.index)
if (entry.type === "history") return language.t("session.prompts.first")
return language.t("session.prompts.overflow", { count: entry.count })
}
const entryActive = (entry: PromptRailEntry) => {
if (entry.type === "prompt") return entry.item.key === props.active()
if (entry.type === "history") return false
const index = items().findIndex((item) => item.key === props.active())
return index >= entry.index && index < entry.index + entry.count
}
const selectFirst = () => {
const item = items()[0]
setHover(item?.key)
if (item) reveal(0)
props.onFirst()
}
const selectLatest = () => {
const index = items().length - 1
const item = items()[index]
setHover(item?.key)
if (item) reveal(index)
props.onLatest()
}
const row = (item: PromptRailItem, index: Accessor<number>) => (
<button
type="button"
class="prompt-rail-row"
classList={{ "prompt-rail-row--hover": item.key === hover() }}
data-prompt-index={index()}
aria-label={label(item, index())}
onMouseEnter={() => setHover(item.key)}
onClick={() => props.onSelect(item)}
>
<span class="prompt-rail-row-prompt" data-queued={item.queued || undefined}>
<Show when={item.queued}>
<span class="prompt-rail-row-status">{language.t("session.prompts.queued")} · </span>
</Show>
{item.prompt}
</span>
<Show when={item.answer || !item.prompt}>
<span class="prompt-rail-row-answer">{item.answer || language.t("session.prompts.noAnswer")}</span>
</Show>
</button>
)
return (
<Show when={items().length >= 2}>
<Show when={entries().length >= 2}>
<nav
ref={rail}
class="prompt-rail"
@@ -157,21 +286,26 @@ export function PromptRail(props: PromptRailProps) {
props.onWheel(event.deltaY)
}}
>
<For each={items()}>
{(item, index) => (
<For each={entries()}>
{(entry, index) => (
<button
type="button"
class="prompt-rail-tick"
classList={{
"prompt-rail-tick--active": item.key === props.active(),
"prompt-rail-tick--open": open() && index() === hover(),
"prompt-rail-tick--active": entryActive(entry),
"prompt-rail-tick--open": open() && index() === focused(),
"prompt-rail-tick--overflow": entry.type !== "prompt",
}}
data-queued={item.queued || undefined}
aria-label={label(item, index())}
tabIndex={index() === (hover() ?? 0) ? 0 : -1}
data-queued={(entry.type === "prompt" && entry.item.queued) || undefined}
aria-label={entryLabel(entry)}
tabIndex={index() === (focused() ?? 0) ? 0 : -1}
onMouseEnter={() => openCard(index())}
onFocus={() => openCard(index())}
onClick={() => props.onSelect(item)}
onClick={() => {
if (entry.type === "prompt") props.onSelect(entry.item)
if (entry.type === "history") selectFirst()
if (entry.type === "overflow") openCard(index())
}}
>
<span class="prompt-rail-tick-line" />
</button>
@@ -185,35 +319,84 @@ export function PromptRail(props: PromptRailProps) {
<div
ref={card}
class="prompt-rail-card"
data-virtualized={virtualized() || undefined}
role="dialog"
aria-label={language.t("session.prompts.navLabel")}
style={{ top: `${position().top}px`, left: `${position().left}px` }}
style={{
top: `${position().top}px`,
left: `${position().left}px`,
"--prompt-rail-card-height": `${position().height}px`,
}}
onMouseEnter={cancelClose}
onMouseLeave={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
// the intent to go further back has to be read from the wheel.
revealing = false
if (event.deltaY < 0) page(offset())
}}
onScroll={() => {
if (card && !virtualized()) page(card.scrollTop)
}}
>
<For each={items()}>
{(item, index) => (
<button
type="button"
class="prompt-rail-row"
classList={{ "prompt-rail-row--hover": index() === hover() }}
onMouseEnter={() => setHover(index())}
onClick={() => props.onSelect(item)}
>
<span class="prompt-rail-row-prompt" data-queued={item.queued || undefined}>
<Show when={item.queued}>
<span class="prompt-rail-row-status">{language.t("session.prompts.queued")} · </span>
</Show>
{item.prompt}
</span>
<Show when={item.answer || !item.prompt}>
<span class="prompt-rail-row-answer">
{item.answer || language.t("session.prompts.noAnswer")}
</span>
</Show>
</button>
)}
</For>
<div class="prompt-rail-card-header">
<span class="prompt-rail-card-title">{language.t("session.prompts.navLabel")}</span>
<div class="prompt-rail-card-actions">
<Tooltip value={language.t("session.prompts.first")} placement="top">
<IconButton
icon="arrow-up"
label={language.t("session.prompts.first")}
aria-label={language.t("session.prompts.first")}
variant="ghost"
size="small"
disabled={props.seeking() || props.loadingOlder()}
onClick={selectFirst}
/>
</Tooltip>
<Tooltip value={language.t("session.prompts.latest")} placement="top">
<IconButton
icon="arrow-down-to-line"
label={language.t("session.prompts.latest")}
aria-label={language.t("session.prompts.latest")}
variant="ghost"
size="small"
onClick={selectLatest}
/>
</Tooltip>
</div>
</div>
<Show when={props.loadingOlder() || props.seeking()}>
<div class="prompt-rail-loading" role="status">
<Spinner />
<span>{language.t("session.messages.loadingEarlier")}</span>
</div>
</Show>
<Show
when={virtualized()}
fallback={
<div class="prompt-rail-list-static">
<For each={items()}>{row}</For>
</div>
}
>
<VList
ref={(handle) => {
list = handle
}}
class="prompt-rail-list"
data={items()}
itemSize={ROW_HEIGHT}
bufferSize={ROW_HEIGHT * 3}
shift={props.prepending()}
onScroll={page}
onScrollEnd={() => {
revealing = false
}}
>
{row}
</VList>
</Show>
</div>
</Portal>
)}
@@ -9,25 +9,40 @@ export interface PromptRailItem {
answer: string
}
export type PromptRailEntry =
| { type: "prompt"; item: PromptRailItem; index: number }
| { type: "overflow"; count: number; index: number }
| { type: "history" }
const PROMPT_LIMIT = 160
const ANSWER_LIMIT = 220
/**
* Height of the tallest card row (padding + a one-line prompt + a two-line
* answer), and the unit the fit cap is measured in. Deliberately the worst
* case rather than an average: "only show what fits" should stay true for a
* card whose rows all wrap, not just for a lucky mix of short ones.
* answer). Sizes the navigator's virtualized rows; the rail's own fit cap is
* measured in tick spacing instead, since a tick is only a hairline.
*/
export const ROW_HEIGHT = 76
/** Vertical padding reserved at the top and bottom of the rail. */
export const RAIL_INSET = 24
/** Natural spacing between ticks, and the tightest they are allowed to pack. */
export const TICK_STEP = 14
export const TICK_MIN = 7
/**
* How many prompts fit the available transcript height. The card and the rail
* always render the same set, so this one number drives both.
* How many ticks fit the available transcript height. Measured in tick
* spacing, not card row height: a tick is a 1.5px line, so the rail holds
* several times more prompts than the navigator can list at once, and
* summarizing at the card's row count would hide prompts that have room to
* show. The complete prompt list lives in the bounded navigator.
*/
export function capacity(height: number): number {
return Math.floor((height - RAIL_INSET) / ROW_HEIGHT)
return Math.floor((height - RAIL_INSET) / TICK_MIN)
}
export function historyAction(before: number, after: number, more: boolean): "stop" | "load" | "jump" {
if (after <= before) return "stop"
return more ? "load" : "jump"
}
// The card never renders markdown — user message text shows literally, and
@@ -92,7 +107,38 @@ export function promptItems(rows: TranscriptRow[]): PromptRailItem[] {
return items
}
export function railItems(items: PromptRailItem[], capacity: number): PromptRailItem[] {
export function railEntries(items: PromptRailItem[], capacity: number, history = false): PromptRailEntry[] {
if (capacity < 1) return []
return items.slice(-capacity)
if (!history && items.length <= capacity) {
return items.map((item, index) => ({ type: "prompt", item, index }))
}
if (capacity === 1) {
if (history) return [{ type: "history" }]
const index = items.length - 1
const item = items[index]
return item ? [{ type: "prompt", item, index }] : []
}
if (capacity === 2) {
const item = items.at(-1)
const latest = item ? [{ type: "prompt" as const, item, index: items.length - 1 }] : []
if (history) return [{ type: "history" }, ...latest]
const first = items[0]
return first ? [{ type: "prompt", item: first, index: 0 }, ...latest] : latest
}
const count = Math.min(items.length, capacity - 2)
const start = items.length - count
const recent = items.slice(start).map((item, offset) => ({
type: "prompt" as const,
item,
index: start + offset,
}))
const prefix: PromptRailEntry[] = history
? [{ type: "history" }]
: items[0]
? [{ type: "prompt", item: items[0], index: 0 }]
: []
const hidden = start - (history ? 0 : 1)
if (hidden < 1) return [...prefix, ...recent]
return [...prefix, { type: "overflow", count: hidden, index: history ? 0 : 1 }, ...recent]
}
@@ -291,7 +291,7 @@ interface SessionContextValue {
createSession: () => void
clearCurrentSession: () => void
loadSessions: () => void
loadOlderMessages: () => void
loadOlderMessages: () => boolean
selectSession: (id: string) => void
deleteSession: (id: string) => void
renameSession: (id: string, title: string) => void
@@ -2560,9 +2560,9 @@ export const SessionProvider: ParentComponent = (props) => {
function loadOlderMessages() {
const id = currentSessionID()
if (!id || !server.isConnected()) return
if (!id || !server.isConnected()) return false
const page = pages[id] ?? emptyPageState
if (!page.hasMore || page.loadingOlder || page.loadingInitial || !page.before) return
if (!page.hasMore || page.loadingOlder || page.loadingInitial || !page.before) return false
patchPage(id, { loadingOlder: true })
vscode.postMessage({
type: "loadMessages",
@@ -2571,6 +2571,7 @@ export const SessionProvider: ParentComponent = (props) => {
before: page.before,
limit: MESSAGE_PAGE_LIMIT,
})
return true
}
// Session whose message fetch was deferred because the backend was offline at
+3
View File
@@ -709,6 +709,9 @@ export const dict = {
"session.prompts.tick": "المطالبة {{index}} من {{total}}: {{prompt}}",
"session.prompts.noAnswer": "لا توجد استجابة بعد",
"session.prompts.queued": "في قائمة الانتظار",
"session.prompts.first": "أول مطالبة",
"session.prompts.latest": "أحدث مطالبة",
"session.prompts.overflow": "{{count}} مطالبات سابقة",
"session.status.writingResponse": "...جارٍ كتابة الرد",
"session.status.retry": "جارٍ إعادة المحاولة…",
"session.status.working": "...جارٍ العمل",
+3
View File
@@ -727,6 +727,9 @@ export const dict = {
"session.prompts.tick": "Prompt {{index}} de {{total}}: {{prompt}}",
"session.prompts.noAnswer": "Sem resposta ainda",
"session.prompts.queued": "Na fila",
"session.prompts.first": "Primeiro prompt",
"session.prompts.latest": "Prompt mais recente",
"session.prompts.overflow": "{{count}} prompts anteriores",
"session.status.writingResponse": "Escrevendo resposta…",
"session.status.retry": "Tentando novamente…",
"session.status.working": "Trabalhando…",
+3
View File
@@ -727,6 +727,9 @@ export const dict = {
"session.prompts.tick": "Upit {{index}} od {{total}}: {{prompt}}",
"session.prompts.noAnswer": "Još nema odgovora",
"session.prompts.queued": "Na čekanju",
"session.prompts.first": "Prvi upit",
"session.prompts.latest": "Najnoviji upit",
"session.prompts.overflow": "{{count}} ranijih upita",
"session.status.writingResponse": "Pisanje odgovora…",
"session.status.retry": "Ponovni pokušaj…",
"session.status.working": "Radim…",
+3
View File
@@ -725,6 +725,9 @@ export const dict = {
"session.prompts.tick": "Prompt {{index}} af {{total}}: {{prompt}}",
"session.prompts.noAnswer": "Intet svar endnu",
"session.prompts.queued": "I kø",
"session.prompts.first": "Første prompt",
"session.prompts.latest": "Seneste prompt",
"session.prompts.overflow": "{{count}} tidligere prompter",
"session.status.writingResponse": "Skriver svar…",
"session.status.retry": "Prøver igen…",
"session.status.working": "Arbejder…",
@@ -738,6 +738,9 @@ export const dict = {
"session.prompts.tick": "Prompt {{index}} von {{total}}: {{prompt}}",
"session.prompts.noAnswer": "Noch keine Antwort",
"session.prompts.queued": "In Warteschlange",
"session.prompts.first": "Erster Prompt",
"session.prompts.latest": "Neuester Prompt",
"session.prompts.overflow": "{{count}} frühere Prompts",
"session.status.writingResponse": "Antwort wird geschrieben…",
"session.status.retry": "Erneuter Versuch…",
"session.status.working": "Wird bearbeitet…",
@@ -679,6 +679,9 @@ export const dict = {
"session.prompts.tick": "Prompt {{index}} of {{total}}: {{prompt}}",
"session.prompts.noAnswer": "No response yet",
"session.prompts.queued": "Queued",
"session.prompts.first": "First prompt",
"session.prompts.latest": "Latest prompt",
"session.prompts.overflow": "{{count}} earlier prompts",
"session.status.writingResponse": "Writing response...",
"session.status.retry": "Retrying…",
"session.status.working": "Working...",
+3
View File
@@ -732,6 +732,9 @@ export const dict = {
"session.prompts.tick": "Prompt {{index}} de {{total}}: {{prompt}}",
"session.prompts.noAnswer": "Aún no hay respuesta",
"session.prompts.queued": "En cola",
"session.prompts.first": "Primera solicitud",
"session.prompts.latest": "Última solicitud",
"session.prompts.overflow": "{{count}} solicitudes anteriores",
"session.status.writingResponse": "Escribiendo respuesta…",
"session.status.retry": "Reintentando…",
"session.status.working": "Trabajando…",
+3
View File
@@ -683,6 +683,9 @@ export const dict = {
"session.prompts.tick": "پرامپت {{index}} از {{total}}: {{prompt}}",
"session.prompts.noAnswer": "هنوز پاسخی وجود ندارد",
"session.prompts.queued": "در صف انتظار",
"session.prompts.first": "اولین پرامپت",
"session.prompts.latest": "آخرین پرامپت",
"session.prompts.overflow": "{{count}} پرامپت قبلی",
"session.status.writingResponse": "در حال نوشتن پاسخ...",
"session.status.retry": "در حال تلاش مجدد…",
"session.status.working": "در حال پردازش...",
+3
View File
@@ -738,6 +738,9 @@ export const dict = {
"session.prompts.tick": "Prompt {{index}} sur {{total}} : {{prompt}}",
"session.prompts.noAnswer": "Pas encore de réponse",
"session.prompts.queued": "En attente",
"session.prompts.first": "Premier prompt",
"session.prompts.latest": "Dernier prompt",
"session.prompts.overflow": "{{count}} prompts précédents",
"session.status.writingResponse": "Rédaction de la réponse…",
"session.status.retry": "Nouvelle tentative…",
"session.status.working": "En cours…",
+3
View File
@@ -578,6 +578,9 @@ export const dict = {
"session.prompts.tick": "Prompt {{index}} di {{total}}: {{prompt}}",
"session.prompts.noAnswer": "Nessuna risposta ancora",
"session.prompts.queued": "In coda",
"session.prompts.first": "Primo prompt",
"session.prompts.latest": "Ultimo prompt",
"session.prompts.overflow": "{{count}} prompt precedenti",
"session.status.writingResponse": "Scrittura risposta...",
"session.status.retry": "Riprovo...",
"session.status.working": "Al lavoro...",
+3
View File
@@ -719,6 +719,9 @@ export const dict = {
"session.prompts.tick": "プロンプト {{index}}/{{total}}: {{prompt}}",
"session.prompts.noAnswer": "まだ応答がありません",
"session.prompts.queued": "キューに追加済み",
"session.prompts.first": "最初のプロンプト",
"session.prompts.latest": "最新のプロンプト",
"session.prompts.overflow": "{{count}} 件前のプロンプト",
"session.status.writingResponse": "応答を作成中…",
"session.status.retry": "再試行中…",
"session.status.working": "作業中…",
+3
View File
@@ -720,6 +720,9 @@ export const dict = {
"session.prompts.tick": "프롬프트 {{index}}/{{total}}: {{prompt}}",
"session.prompts.noAnswer": "아직 응답이 없습니다",
"session.prompts.queued": "대기 중",
"session.prompts.first": "첫 번째 프롬프트",
"session.prompts.latest": "최신 프롬프트",
"session.prompts.overflow": "{{count}}개 이전 프롬프트",
"session.status.writingResponse": "응답 작성 중...",
"session.status.retry": "재시도 중…",
"session.status.working": "작업 중...",
+3
View File
@@ -717,6 +717,9 @@ export const dict = {
"session.prompts.tick": "Prompt {{index}} van {{total}}: {{prompt}}",
"session.prompts.noAnswer": "Nog geen reactie",
"session.prompts.queued": "In wachtrij",
"session.prompts.first": "Eerste prompt",
"session.prompts.latest": "Meest recente prompt",
"session.prompts.overflow": "{{count}} eerdere prompts",
"session.status.writingResponse": "Antwoord schrijven...",
"session.status.retry": "Opnieuw proberen...",
"session.status.working": "Bezig...",
+3
View File
@@ -687,6 +687,9 @@ export const dict = {
"session.prompts.tick": "Ledetekst {{index}} av {{total}}: {{prompt}}",
"session.prompts.noAnswer": "Ingen svar ennå",
"session.prompts.queued": "I kø",
"session.prompts.first": "Første ledetekst",
"session.prompts.latest": "Siste ledetekst",
"session.prompts.overflow": "{{count}} tidligere ledetekster",
"session.status.writingResponse": "Skriver svar…",
"session.status.retry": "Prøver på nytt…",
"session.status.working": "Arbeider…",
+3
View File
@@ -683,6 +683,9 @@ export const dict = {
"session.prompts.tick": "Prompt {{index}} z {{total}}: {{prompt}}",
"session.prompts.noAnswer": "Jeszcze brak odpowiedzi",
"session.prompts.queued": "W kolejce",
"session.prompts.first": "Pierwszy prompt",
"session.prompts.latest": "Ostatni prompt",
"session.prompts.overflow": "{{count}} wcześniejszych promptów",
"session.status.writingResponse": "Pisanie odpowiedzi…",
"session.status.retry": "Ponawianie…",
"session.status.working": "Pracuję…",
+3
View File
@@ -724,6 +724,9 @@ export const dict = {
"session.prompts.tick": "Промпт {{index}} из {{total}}: {{prompt}}",
"session.prompts.noAnswer": "Ответа пока нет",
"session.prompts.queued": "В очереди",
"session.prompts.first": "Первый запрос",
"session.prompts.latest": "Последний запрос",
"session.prompts.overflow": "{{count}} предыдущих запросов",
"session.status.writingResponse": "Пишу ответ…",
"session.status.retry": "Повторная попытка…",
"session.status.working": "Работаю…",
+3
View File
@@ -716,6 +716,9 @@ export const dict = {
"session.prompts.tick": "พรอมต์ {{index}} จาก {{total}}: {{prompt}}",
"session.prompts.noAnswer": "ยังไม่มีการตอบกลับ",
"session.prompts.queued": "อยู่ในคิว",
"session.prompts.first": "พรอมต์แรก",
"session.prompts.latest": "พรอมต์ล่าสุด",
"session.prompts.overflow": "พรอมต์ก่อนหน้า {{count}} รายการ",
"session.status.writingResponse": "กำลังเขียนคำตอบ...",
"session.status.retry": "กำลังลองใหม่…",
"session.status.working": "กำลังทำงาน...",
+3
View File
@@ -711,6 +711,9 @@ export const dict = {
"session.prompts.tick": "Komut {{index}} / {{total}}: {{prompt}}",
"session.prompts.noAnswer": "Henüz yanıt yok",
"session.prompts.queued": "Sırada",
"session.prompts.first": "İlk istem",
"session.prompts.latest": "En son istem",
"session.prompts.overflow": "{{count}} önceki istem",
"session.status.writingResponse": "Yanıt yazılıyor...",
"session.status.retry": "Yeniden deneniyor…",
"session.status.working": "Çalışıyor...",
+3
View File
@@ -713,6 +713,9 @@ export const dict = {
"session.prompts.tick": "Запит {{index}} з {{total}}: {{prompt}}",
"session.prompts.noAnswer": "Відповіді ще немає",
"session.prompts.queued": "У черзі",
"session.prompts.first": "Перший запит",
"session.prompts.latest": "Останній запит",
"session.prompts.overflow": "{{count}} попередніх запитів",
"session.status.writingResponse": "Пишу відповідь...",
"session.status.retry": "Повторна спроба…",
"session.status.working": "Працює...",
+3
View File
@@ -700,6 +700,9 @@ export const dict = {
"session.prompts.tick": "提示词 {{index}}/{{total}}{{prompt}}",
"session.prompts.noAnswer": "暂无响应",
"session.prompts.queued": "已排队",
"session.prompts.first": "首个提示",
"session.prompts.latest": "最新提示",
"session.prompts.overflow": "{{count}} 个更早的提示",
"session.status.writingResponse": "正在撰写回复…",
"session.status.retry": "正在重试…",
"session.status.working": "处理中…",
+3
View File
@@ -660,6 +660,9 @@ export const dict = {
"session.prompts.tick": "提示詞 {{index}}/{{total}}{{prompt}}",
"session.prompts.noAnswer": "尚無回應",
"session.prompts.queued": "已排入佇列",
"session.prompts.first": "第一個提示",
"session.prompts.latest": "最新提示",
"session.prompts.overflow": "{{count}} 個較早的提示",
"session.status.writingResponse": "正在撰寫回覆…",
"session.status.retry": "正在重試…",
"session.status.working": "處理中…",
@@ -262,7 +262,7 @@ export function mockSessionValue(overrides?: {
createSession: noop,
clearCurrentSession: noop,
loadSessions: noop,
loadOlderMessages: noop,
loadOlderMessages: () => false,
selectSession: noop,
deleteSession: noop,
renameSession: noop,
@@ -9,6 +9,7 @@
import type { Meta, StoryObj } from "storybook-solidjs-vite"
import type { AssistantMessage } from "@kilocode/sdk/v2"
import { batch, createSignal } from "solid-js"
import { StoryProviders, defaultMockData, mockSessionValue } from "./StoryProviders"
import { ChatView } from "../components/chat/ChatView"
import { ErrorDisplay } from "../components/chat/ErrorDisplay"
@@ -698,6 +699,7 @@ const renderRailChat = (status: "idle" | "busy" = "idle") => {
...mockSessionValue({ id: SESSION_ID, status }),
messages: () => railMessages,
userMessages: () => railMessages.filter((msg) => msg.role === "user"),
getParts: (id: string) => railParts[id] ?? [],
}
return (
<StoryProviders data={railData} sessionID={SESSION_ID} status={status} noPadding>
@@ -722,10 +724,11 @@ export const PromptRailSidebar: Story = {
// 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: 40 }, (_, i) =>
const manyTurns = Array.from({ length: 80 }, (_, i) =>
railTurn(100 + i, `Prompt number ${i + 1} in a long running session`, `Answer number ${i + 1}.`),
)
const manyMessages = manyTurns.flatMap((turn) => turn.messages)
const recentMessages = manyTurns.slice(-40).flatMap((turn) => turn.messages)
const manyData = {
...defaultMockData,
message: { [SESSION_ID]: manyMessages },
@@ -735,10 +738,34 @@ const manyData = {
export const PromptRailManyPrompts: Story = {
name: "PromptRail - long session caps to what fits",
render: () => {
const [messages, setMessages] = createSignal(recentMessages)
const [older, setOlder] = createSignal(true)
const [loading, setLoading] = createSignal(false)
const [mutation, setMutation] = createSignal<"prepend">()
const load = () => {
if (!older() || loading()) return false
setLoading(true)
// Paging is a backend round trip, so the story keeps a short delay: the
// navigator's loading row is part of the behavior being shown.
setTimeout(() => {
batch(() => {
setMessages(manyMessages)
setOlder(false)
setMutation("prepend")
setLoading(false)
})
}, 300)
return true
}
const session = {
...mockSessionValue({ id: SESSION_ID, status: "idle" }),
messages: () => manyMessages,
userMessages: () => manyMessages.filter((msg) => msg.role === "user"),
messages,
userMessages: () => messages().filter((msg) => msg.role === "user"),
getParts: (id: string) => manyData.part[id] ?? [],
hasOlderMessages: older,
loadingOlderMessages: loading,
messageMutation: mutation,
loadOlderMessages: load,
}
return (
<StoryProviders data={manyData} sessionID={SESSION_ID} status="idle" noPadding>
@@ -70,6 +70,17 @@
opacity: 0.5;
}
.prompt-rail-tick--overflow .prompt-rail-tick-line {
width: 6px;
background: repeating-linear-gradient(
to right,
var(--icon-weaker) 0,
var(--icon-weaker) 2px,
transparent 2px,
transparent 4px
);
}
/* Scroll position: subtle, always-on cue. */
.prompt-rail-tick--active .prompt-rail-tick-line {
width: 13px;
@@ -103,7 +114,7 @@
z-index: 1000;
box-sizing: border-box;
width: min(360px, calc(100vw - var(--prompt-rail-width, 16px) - 40px));
max-height: calc(100vh - 24px);
max-height: min(calc(100vh - 24px), var(--prompt-rail-card-height, calc(100vh - 24px)));
overflow-y: auto;
padding: 6px;
border-radius: 12px;
@@ -120,7 +131,79 @@
0 12px 32px -8px rgba(0, 0, 0, 0.4);
animation: prompt-rail-in 0.22s var(--prompt-rail-ease, cubic-bezier(0.22, 1, 0.36, 1));
transform-origin: left center;
scrollbar-width: thin;
}
.prompt-rail-card[data-virtualized] {
display: flex;
flex-direction: column;
height: min(calc(100vh - 24px), var(--prompt-rail-card-height));
overflow: hidden;
background: var(--surface-float-base);
}
.prompt-rail-card-header {
position: sticky;
top: -6px;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 32px;
padding: 4px 5px 4px 11px;
background: color-mix(in srgb, var(--surface-float-base) 88%, transparent);
backdrop-filter: blur(20px) saturate(1.6);
-webkit-backdrop-filter: blur(20px) saturate(1.6);
}
.prompt-rail-card-title {
min-width: 0;
overflow: hidden;
color: var(--text-weak);
font-size: var(--kilo-font-size-11);
font-weight: var(--font-weight-medium, 500);
text-overflow: ellipsis;
white-space: nowrap;
}
.prompt-rail-card-actions {
display: flex;
align-items: center;
flex: none;
gap: 2px;
}
.prompt-rail-loading {
display: flex;
flex: none;
align-items: center;
justify-content: center;
gap: 8px;
padding: 6px 0 8px;
color: var(--text-weaker);
font-size: var(--kilo-font-size-12);
}
.prompt-rail-list {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.prompt-rail-list-static {
min-width: 0;
}
/* A floating popover should not paint an OS scrollbar over its own glass; the
rail already shows where you are in the session. */
.prompt-rail-card,
.prompt-rail-list {
scrollbar-width: none;
}
.prompt-rail-card::-webkit-scrollbar,
.prompt-rail-list::-webkit-scrollbar {
display: none;
}
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {