diff --git a/.changeset/long-session-prompt-navigation.md b/.changeset/long-session-prompt-navigation.md new file mode 100644 index 0000000000..967dde901f --- /dev/null +++ b/.changeset/long-session-prompt-navigation.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Navigate long conversations from a compact prompt rail that loads earlier history as you scroll. diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-many-prompts-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-many-prompts-chromium-linux.png index df4e9c7068..2ed225e9ec 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-many-prompts-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-many-prompts-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:51adb9e31ce0bc82981b0f20f895ce4ede3f92828546115f929942ef93bf0812 -size 11204 +oid sha256:d7fc23fcb7adf483c0b771ef601b23cb365dc7f40033b00fce703b35910aa4fc +size 27159 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-sidebar-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-sidebar-chromium-linux.png index 94024986cd..623df73220 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-sidebar-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-sidebar-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c5658ed9e5266311c4239b7cd470d2f772d1cc0b3fd6c6b00337d4f3141a3a4d -size 11950 +oid sha256:d11f4004ed3170647d385c14df077d235f5bb9bd6e5dec07557ee2014d553233 +size 27302 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-wide-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-wide-chromium-linux.png index 07e3e945ef..c2410b066a 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-wide-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-wide-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:095ed97dc46498be6fd75483b24357ac0740b6f2c1544f6c103a1dac360e1a7e -size 11202 +oid sha256:f8bd56ba87d0c2bbef8a2325ab2e9f0e956c89e2602ff8ca7415d5fb7c9fa1f3 +size 29709 diff --git a/packages/kilo-vscode/tests/unit/prompt-rail.test.ts b/packages/kilo-vscode/tests/unit/prompt-rail.test.ts index d9854b5226..18c800e1bb 100644 --- a/packages/kilo-vscode/tests/unit/prompt-rail.test.ts +++ b/packages/kilo-vscode/tests/unit/prompt-rail.test.ts @@ -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([]) }) }) 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 d2daa6389b..e5aa67b906 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx @@ -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 = (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 = (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(`[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(`[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 = (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() - 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 = (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 = (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 = (props) => { 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())} /> 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 555cc337a0..857e4fa634 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptRail.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptRail.tsx @@ -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 items: Accessor /** Row key of the item whose turn is currently at the top of the transcript. */ active: Accessor 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 + hasOlder: Accessor + loadingOlder: Accessor + prepending: Accessor + seeking: Accessor } 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() - const [anchor, setAnchor] = createSignal<{ top: number; left: number }>() + const [hover, setHover] = createSignal() + const [focused, setFocused] = createSignal() + 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 | 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(`[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) => ( + + ) + return ( - = 2}> + = 2}>