diff --git a/.changeset/prompt-rail.md b/.changeset/prompt-rail.md new file mode 100644 index 00000000000..69ab09ababf --- /dev/null +++ b/.changeset/prompt-rail.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Add a prompt navigator rail to the chat transcript. A thin rail of ticks on the left edge shows one tick per prompt you sent; hovering or focusing it expands a card listing those prompts with a short preview of the answer, and clicking jumps the transcript to that turn. It appears in the sidebar, Kilo editor tabs, the sub-agent viewer, and Agent Manager, and never changes the readable width of the chat. 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 new file mode 100644 index 00000000000..df4e9c70689 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-many-prompts-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:51adb9e31ce0bc82981b0f20f895ce4ede3f92828546115f929942ef93bf0812 +size 11204 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 new file mode 100644 index 00000000000..94024986cda --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-sidebar-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c5658ed9e5266311c4239b7cd470d2f772d1cc0b3fd6c6b00337d4f3141a3a4d +size 11950 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 new file mode 100644 index 00000000000..07e3e945eff --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-wide-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:095ed97dc46498be6fd75483b24357ac0740b6f2c1544f6c103a1dac360e1a7e +size 11202 diff --git a/packages/kilo-vscode/tests/unit/prompt-rail.test.ts b/packages/kilo-vscode/tests/unit/prompt-rail.test.ts new file mode 100644 index 00000000000..d9854b52261 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/prompt-rail.test.ts @@ -0,0 +1,192 @@ +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" + +const base = { + sessionID: "session", + createdAt: "2026-01-01T00:00:00.000Z", + time: { created: 1 }, +} + +const user = (id: string, opts: Partial = {}): Message => ({ ...base, id, role: "user", ...opts }) +const assistant = (id: string, parentID: string, opts: Partial = {}): Message => ({ + ...base, + id, + parentID, + role: "assistant", + ...opts, +}) +const text = (id: string, messageID: string, value: string, opts: Partial = {}): Part => ({ + id, + messageID, + type: "text", + text: value, + ...opts, +}) +const tool = (id: string, messageID: string, title: string): Part => ({ + id, + messageID, + type: "tool", + tool: "bash", + state: { status: "completed", input: {}, output: "", title }, +}) +const lookup = (values: Record) => (id: string) => values[id] ?? [] + +describe("previewText", () => { + it("collapses whitespace and keeps plain text", () => { + expect(previewText(" hello\n\nworld ")).toBe("hello world") + }) + + it("drops fenced code blocks and inline code", () => { + expect(previewText("before `const x = 1` after\n```\nconst y = 2\n```\nend")).toBe("before after end") + }) + + it("keeps link labels but drops URLs and images", () => { + expect(previewText("see [the docs](https://example.com) and ![shot](img.png) now")).toBe("see the docs and now") + }) + + it("keeps bracket text inside inline code literal", () => { + expect(previewText("echo `[label](url)` verbatim")).toBe("echo verbatim") + }) + + it("strips heading, list, and quote markers", () => { + expect(previewText("# Title\n- one\n> two\nthree")).toBe("Title one two three") + }) +}) + +describe("promptItems", () => { + it("emits one item per non-partial user turn with prompt and answer", () => { + const u1 = user("u1") + const a1 = assistant("a1", "u1") + const u2 = user("u2") + const a2 = assistant("a2", "u2") + const parts = { + u1: [text("up1", "u1", "add authentication")], + a1: [text("ap1", "a1", "Done, auth is wired up.")], + u2: [text("up2", "u2", "now fix the bug")], + a2: [text("ap2", "a2", "Fixed it.")], + } + const rows = transcriptRows(messageTurns([u1, a1, u2, a2]), lookup(parts)) + + const items = promptItems(rows) + + expect(items).toEqual([ + { key: "u1:user", turn: "u1", queued: false, prompt: "add authentication", answer: "Done, auth is wired up." }, + { key: "u2:user", turn: "u2", queued: false, prompt: "now fix the bug", answer: "Fixed it." }, + ]) + }) + + it("keeps assistant chunks of one turn joined", () => { + const u1 = user("u1") + const a1 = assistant("a1", "u1") + const parts = { + u1: [text("up1", "u1", "go")], + a1: [text("ap1", "a1", "First."), text("ap2", "a1", "Second.")], + } + const rows = transcriptRows(messageTurns([u1, a1]), lookup(parts)) + + expect(promptItems(rows)[0]?.answer).toBe("First. Second.") + }) + + it("skips synthetic parts and leaves an empty answer for tool-only turns", () => { + const u1 = user("u1") + const a1 = assistant("a1", "u1") + const parts = { + u1: [text("up1", "u1", "run it"), text("up2", "u1", "internal", { synthetic: true })], + a1: [tool("at1", "a1", "Run tests"), text("at2", "a1", "hidden", { synthetic: true })], + } + const rows = transcriptRows(messageTurns([u1, a1]), lookup(parts)) + + expect(promptItems(rows)[0]).toMatchObject({ prompt: "run it", answer: "" }) + }) + + it("marks queued rows", () => { + const u1 = user("u1") + const u2 = user("u2") + const rows = transcriptRows(messageTurns([u1, u2]), lookup({}), { queued: new Set(["u2"]) }) + + expect(promptItems(rows).map((item) => item.queued)).toEqual([false, true]) + }) + + it("truncates long prompts and answers", () => { + const u1 = user("u1") + const a1 = assistant("a1", "u1") + const parts = { + u1: [text("up1", "u1", "x".repeat(400))], + a1: [text("ap1", "a1", "y".repeat(400))], + } + const rows = transcriptRows(messageTurns([u1, a1]), lookup(parts)) + + const [item] = promptItems(rows) + expect(item!.prompt.length).toBe(160) + expect(item!.prompt.endsWith("…")).toBe(true) + expect(item!.answer.length).toBe(220) + expect(item!.answer.endsWith("…")).toBe(true) + }) + + it("omits partial turns (assistant-only leads)", () => { + const a1 = assistant("a1", "u1") + const rows = transcriptRows(messageTurns([a1]), lookup({ a1: [text("p1", "a1", "hello")] })) + + expect(promptItems(rows)).toEqual([]) + }) + + it("promotes the answer when the prompt carries no text (image-only message)", () => { + const u1 = user("u1") + const a1 = assistant("a1", "u1") + const parts = { + u1: [{ id: "up1", messageID: "u1", type: "file", mime: "image/png", url: "data:," } as Part], + a1: [text("ap1", "a1", "That screenshot shows the rail overlapping the gutter.")], + } + const rows = transcriptRows(messageTurns([u1, a1]), lookup(parts)) + + expect(promptItems(rows)[0]).toMatchObject({ + prompt: "That screenshot shows the rail overlapping the gutter.", + answer: "", + }) + }) + + it("leaves both empty when neither prompt nor answer has text", () => { + const u1 = user("u1") + const rows = transcriptRows(messageTurns([u1]), lookup({})) + + expect(promptItems(rows)[0]).toMatchObject({ prompt: "", answer: "" }) + }) +}) + +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("returns nothing usable for unmeasured or tiny transcripts", () => { + expect(capacity(0)).toBeLessThan(1) + expect(capacity(99)).toBeLessThan(1) + }) +}) + +describe("railItems", () => { + const items = Array.from({ length: 5 }, (_, i) => ({ + key: `k${i}`, + turn: `t${i}`, + queued: false, + prompt: `p${i}`, + answer: `a${i}`, + })) + + it("passes through when everything fits", () => { + expect(railItems(items, 5)).toEqual(items) + expect(railItems(items, 10)).toEqual(items) + }) + + it("keeps the newest items when capacity is smaller", () => { + expect(railItems(items, 2)).toEqual(items.slice(-2)) + }) + + it("returns nothing at zero capacity", () => { + expect(railItems(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 40e0b72876a..d2daa6389b1 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx @@ -70,6 +70,8 @@ import { type TranscriptHold, type TranscriptRow, } from "../../context/transcript-rows" +import { PromptRail } from "./PromptRail" +import { capacity, promptItems, railItems, 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" @@ -164,6 +166,8 @@ export const MessageList: Component = (props) => { const [scrollEl, setScrollEl] = createSignal() const [virtualizer, setVirtualizer] = createSignal() const [layout, setLayout] = createSignal("") + // Transcript height, kept reactive so the prompt rail re-caps on resize. + const [height, setHeight] = createSignal(0) const revert = () => session.revert() ?? undefined const turns = createMemo((prev: MessageTurn[] | undefined) => @@ -944,6 +948,21 @@ export const MessageList: Component = (props) => { const keys = createMemo(() => partition().virtual.map((row) => row.key)) const fingerprint = createMemo(() => rowFingerprint(keys())) + // 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" }) + return + } + const el = scrollEl() + const target = el?.querySelector(`[data-row-key="${CSS.escape(key)}"]`) + target?.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 @@ -956,19 +975,50 @@ export const MessageList: Component = (props) => { // actually contains the clicked part, not just the message's first chunk. const row = matches.find((r) => r.type === "assistant" && r.parts.some((p) => p.id === detail.partId)) ?? matches[0] if (!row) return - autoScroll.pause() - const index = keys().indexOf(row.key) - if (index >= 0) { - virtualizer()?.scrollToIndex(index, { align: "start" }) - return - } - const el = scrollEl() - const target = el?.querySelector(`[data-row-key="${CSS.escape(row.key)}"]`) - target?.scrollIntoView({ block: "start" }) + jump(row.key) } window.addEventListener("scrollToMessage", onScrollToMessage) onCleanup(() => window.removeEventListener("scrollToMessage", onScrollToMessage)) + // Prompt rail: one tick per user prompt, positioned to the left of the + // readable lane, opening a card of prompt/answer previews on hover. + 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 [activeTurn, setActiveTurn] = createSignal() + const railActiveKey = createMemo(() => shown().find((item) => item.turn === activeTurn())?.key) + + const trackActive = () => { + const list = shown() + if (list.length === 0) return setActiveTurn(undefined) + const handle = virtualizer() + const offset = handle?.scrollOffset + if (handle && offset !== undefined && offset > 1) { + const row = partition().virtual[handle.findItemIndex(offset)] + if (row) return setActiveTurn(row.turn) + } + setActiveTurn(list.at(-1)?.turn) + } + let activeFrame: number | undefined + const scheduleActive = () => { + if (activeFrame !== undefined) return + activeFrame = requestAnimationFrame(() => { + activeFrame = undefined + trackActive() + }) + } + onCleanup(() => { + if (activeFrame !== undefined) cancelAnimationFrame(activeFrame) + }) + // Re-derive the active turn whenever the transcript changes so the rail + // reflects a newly started turn even before any scrolling happens. + createEffect(() => { + shown() + partition() + scheduleActive() + }) + // Highlights the part behind the currently hovered/focused timeline bar // (dispatched by TaskTimeline) so the two stay visually correlated. const [highlight, setHighlight] = createSignal() @@ -1018,6 +1068,7 @@ export const MessageList: Component = (props) => { const handleScroll = () => { autoScroll.handleScroll() maybeLoadOlder() + scheduleActive() if (search.active()) scheduleHighlight() } @@ -1026,6 +1077,7 @@ export const MessageList: Component = (props) => { const el = scrollEl() if (!el) return const style = getComputedStyle(el) + setHeight(el.clientHeight) setLayout( layoutFingerprint({ width: Math.round(el.clientWidth), @@ -1210,6 +1262,17 @@ export const MessageList: Component = (props) => { + railActiveKey()} + onSelect={(item: PromptRailItem) => jump(item.key)} + onWheel={(deltaY: number) => { + const el = scrollEl() + if (el) el.scrollTop += deltaY + }} + height={height} + /> + + )} + + + + + {(position) => ( + + + + )} + + + ) +} diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/prompt-rail.ts b/packages/kilo-vscode/webview-ui/src/components/chat/prompt-rail.ts new file mode 100644 index 00000000000..e20de18cfc4 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/components/chat/prompt-rail.ts @@ -0,0 +1,98 @@ +import type { Part } from "../../types/messages" +import type { TranscriptRow } from "../../context/transcript-rows" + +export interface PromptRailItem { + key: string + turn: string + queued: boolean + prompt: string + answer: string +} + +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. + */ +export const ROW_HEIGHT = 76 +/** Vertical padding reserved at the top and bottom of the rail. */ +export const RAIL_INSET = 24 + +/** + * How many prompts fit the available transcript height. The card and the rail + * always render the same set, so this one number drives both. + */ +export function capacity(height: number): number { + return Math.floor((height - RAIL_INSET) / ROW_HEIGHT) +} + +// The card never renders markdown — user message text shows literally, and +// assistant text should too. Code spans (inline and fenced) are dropped, and +// link URLs / images are stripped rather than parsed (mirrors MessageList's +// stripMarkdownLinkUrls split so bracket text inside inline code stays out, +// not half-stripped). +export function previewText(raw: string): string { + const segments = raw.split(/(```[\s\S]*?```|`[^`\n]*`)/g) + const text = segments.map((segment, i) => (i % 2 === 1 ? "" : stripLinks(segment))).join(" ") + return text + .replace(/^#{1,6}\s+/gm, "") + .replace(/^\s*[-*>+]\s+/gm, "") + .replace(/\s+/g, " ") + .trim() +} + +function stripLinks(text: string) { + return text.replace(/!\[[^\]]*\]\([^)]*\)/g, "").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") +} + +function text(parts: Part[], limit: number): string { + const joined = parts + .filter((part) => part.type === "text" && !part.synthetic && part.text.trim()) + .map((part) => (part.type === "text" ? part.text : "")) + .join("\n") + return truncate(previewText(joined), limit) +} + +function truncate(value: string, limit: number) { + return value.length <= limit ? value : `${value.slice(0, limit - 1)}…` +} + +export function promptItems(rows: TranscriptRow[]): PromptRailItem[] { + const items: PromptRailItem[] = [] + for (const row of rows) { + if (row.type !== "user") continue + items.push({ key: row.key, turn: row.turn, queued: row.queued, prompt: text(row.parts, PROMPT_LIMIT), answer: "" }) + } + // Answer text is grouped by turn so one pass fills every item; assistant + // rows follow their user row and carry the same `turn` id. + let index = 0 + for (const row of rows) { + if (row.type === "user") { + index += 1 + continue + } + if (row.type !== "assistant" || index === 0) continue + const item = items[index - 1]! + if (item.turn !== row.turn || item.answer) continue + const value = text(row.parts, ANSWER_LIMIT) + if (value) item.answer = value + } + // A prompt can carry no text at all (image-only or file-only message). Rather + // than render a blank row, promote the answer into the label so the row still + // says something; if neither has text the card falls back to its placeholder. + for (const item of items) { + if (item.prompt || !item.answer) continue + item.prompt = truncate(item.answer, PROMPT_LIMIT) + item.answer = "" + } + return items +} + +export function railItems(items: PromptRailItem[], capacity: number): PromptRailItem[] { + if (capacity < 1) return [] + return items.slice(-capacity) +} diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index 592ae22dcf8..7060ab1619a 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -705,6 +705,10 @@ export const dict = { "session.messages.scrollToBottom": "التمرير إلى الأسفل", "session.messages.initializing": "جاري التهيئة...", "session.messages.taskStarting": "جاري البدء...", + "session.prompts.navLabel": "مستعرض المطالبات", + "session.prompts.tick": "المطالبة {{index}} من {{total}}: {{prompt}}", + "session.prompts.noAnswer": "لا توجد استجابة بعد", + "session.prompts.queued": "في قائمة الانتظار", "session.status.writingResponse": "...جارٍ كتابة الرد", "session.status.retry": "جارٍ إعادة المحاولة…", "session.status.working": "...جارٍ العمل", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index 03055f35438..cee0df6bb94 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -723,6 +723,10 @@ export const dict = { "session.messages.scrollToBottom": "Rolar para o final", "session.messages.initializing": "O teraouiñ...", "session.messages.taskStarting": "O kregiñ...", + "session.prompts.navLabel": "Navegador de prompts", + "session.prompts.tick": "Prompt {{index}} de {{total}}: {{prompt}}", + "session.prompts.noAnswer": "Sem resposta ainda", + "session.prompts.queued": "Na fila", "session.status.writingResponse": "Escrevendo resposta…", "session.status.retry": "Tentando novamente…", "session.status.working": "Trabalhando…", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index 5575a758b89..b52183b7128 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -723,6 +723,10 @@ export const dict = { "session.messages.scrollToBottom": "Pomakni se na dno", "session.messages.initializing": "Inicijalizacija...", "session.messages.taskStarting": "Pokretanje...", + "session.prompts.navLabel": "Navigator upita", + "session.prompts.tick": "Upit {{index}} od {{total}}: {{prompt}}", + "session.prompts.noAnswer": "Još nema odgovora", + "session.prompts.queued": "Na čekanju", "session.status.writingResponse": "Pisanje odgovora…", "session.status.retry": "Ponovni pokušaj…", "session.status.working": "Radim…", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index 90ff0250871..7588115fb6b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -721,6 +721,10 @@ export const dict = { "session.messages.scrollToBottom": "Rul til bunden", "session.messages.initializing": "Initialiserer...", "session.messages.taskStarting": "Starter...", + "session.prompts.navLabel": "Promptnavigator", + "session.prompts.tick": "Prompt {{index}} af {{total}}: {{prompt}}", + "session.prompts.noAnswer": "Intet svar endnu", + "session.prompts.queued": "I kø", "session.status.writingResponse": "Skriver svar…", "session.status.retry": "Prøver igen…", "session.status.working": "Arbejder…", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index 811b9ee0fdb..05cabab2378 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -734,6 +734,10 @@ export const dict = { "session.messages.scrollToBottom": "Nach unten scrollen", "session.messages.initializing": "Initialisierung...", "session.messages.taskStarting": "Wird gestartet...", + "session.prompts.navLabel": "Prompt-Navigation", + "session.prompts.tick": "Prompt {{index}} von {{total}}: {{prompt}}", + "session.prompts.noAnswer": "Noch keine Antwort", + "session.prompts.queued": "In Warteschlange", "session.status.writingResponse": "Antwort wird geschrieben…", "session.status.retry": "Erneuter Versuch…", "session.status.working": "Wird bearbeitet…", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index 7cc55121fe8..140cc4b3e0b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -675,6 +675,10 @@ export const dict = { "session.messages.scrollToBottom": "Scroll to bottom", "session.messages.initializing": "Initializing...", "session.messages.taskStarting": "Starting...", + "session.prompts.navLabel": "Prompt navigator", + "session.prompts.tick": "Prompt {{index}} of {{total}}: {{prompt}}", + "session.prompts.noAnswer": "No response yet", + "session.prompts.queued": "Queued", "session.status.writingResponse": "Writing response...", "session.status.retry": "Retrying…", "session.status.working": "Working...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index 7981f2a5515..ec9a24ba856 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -728,6 +728,10 @@ export const dict = { "session.messages.scrollToBottom": "Desplazar al final", "session.messages.initializing": "Inicializando...", "session.messages.taskStarting": "Iniciando...", + "session.prompts.navLabel": "Navegador de prompts", + "session.prompts.tick": "Prompt {{index}} de {{total}}: {{prompt}}", + "session.prompts.noAnswer": "Aún no hay respuesta", + "session.prompts.queued": "En cola", "session.status.writingResponse": "Escribiendo respuesta…", "session.status.retry": "Reintentando…", "session.status.working": "Trabajando…", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index 451b0a1bc66..8a5ec0d7147 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -734,6 +734,10 @@ export const dict = { "session.messages.scrollToBottom": "Défiler vers le bas", "session.messages.initializing": "Initialisation...", "session.messages.taskStarting": "Démarrage...", + "session.prompts.navLabel": "Navigateur de prompts", + "session.prompts.tick": "Prompt {{index}} sur {{total}} : {{prompt}}", + "session.prompts.noAnswer": "Pas encore de réponse", + "session.prompts.queued": "En attente", "session.status.writingResponse": "Rédaction de la réponse…", "session.status.retry": "Nouvelle tentative…", "session.status.working": "En cours…", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/it.ts b/packages/kilo-vscode/webview-ui/src/i18n/it.ts index 066cd300989..8b62af14dcd 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/it.ts @@ -574,6 +574,10 @@ export const dict = { "session.messages.scrollToBottom": "Scorri in fondo", "session.messages.initializing": "Inizializzazione...", "session.messages.taskStarting": "Avvio...", + "session.prompts.navLabel": "Navigatore dei prompt", + "session.prompts.tick": "Prompt {{index}} di {{total}}: {{prompt}}", + "session.prompts.noAnswer": "Nessuna risposta ancora", + "session.prompts.queued": "In coda", "session.status.writingResponse": "Scrittura risposta...", "session.status.retry": "Riprovo...", "session.status.working": "Al lavoro...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index b338abd210f..101302fce7c 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -715,6 +715,10 @@ export const dict = { "session.messages.scrollToBottom": "下にスクロール", "session.messages.initializing": "初期化中...", "session.messages.taskStarting": "開始中...", + "session.prompts.navLabel": "プロンプトナビゲーター", + "session.prompts.tick": "プロンプト {{index}}/{{total}}: {{prompt}}", + "session.prompts.noAnswer": "まだ応答がありません", + "session.prompts.queued": "キューに追加済み", "session.status.writingResponse": "応答を作成中…", "session.status.retry": "再試行中…", "session.status.working": "作業中…", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index 20fc092585d..46cf8910cc3 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -716,6 +716,10 @@ export const dict = { "session.messages.scrollToBottom": "하단으로 스크롤", "session.messages.initializing": "초기화 중...", "session.messages.taskStarting": "시작 중...", + "session.prompts.navLabel": "프롬프트 탐색기", + "session.prompts.tick": "프롬프트 {{index}}/{{total}}: {{prompt}}", + "session.prompts.noAnswer": "아직 응답이 없습니다", + "session.prompts.queued": "대기 중", "session.status.writingResponse": "응답 작성 중...", "session.status.retry": "재시도 중…", "session.status.working": "작업 중...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts index 5f0d90da4be..a325ae0258d 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -713,6 +713,10 @@ export const dict = { "session.messages.scrollToBottom": "Scroll naar beneden", "session.messages.initializing": "Initialiseren...", "session.messages.taskStarting": "Starten...", + "session.prompts.navLabel": "Promptnavigator", + "session.prompts.tick": "Prompt {{index}} van {{total}}: {{prompt}}", + "session.prompts.noAnswer": "Nog geen reactie", + "session.prompts.queued": "In wachtrij", "session.status.writingResponse": "Antwoord schrijven...", "session.status.retry": "Opnieuw proberen...", "session.status.working": "Bezig...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index f9a0d5f0964..2a5564f3aa2 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -683,6 +683,10 @@ export const dict = { "session.messages.scrollToBottom": "Rull til bunnen", "session.messages.initializing": "Initialiserer...", "session.messages.taskStarting": "Starter...", + "session.prompts.navLabel": "Ledetekstnavigering", + "session.prompts.tick": "Ledetekst {{index}} av {{total}}: {{prompt}}", + "session.prompts.noAnswer": "Ingen svar ennå", + "session.prompts.queued": "I kø", "session.status.writingResponse": "Skriver svar…", "session.status.retry": "Prøver på nytt…", "session.status.working": "Arbeider…", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index 99741deb925..92d6f9d2134 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -679,6 +679,10 @@ export const dict = { "session.messages.scrollToBottom": "Przewiń na dół", "session.messages.initializing": "Inicjalizacja...", "session.messages.taskStarting": "Uruchamianie...", + "session.prompts.navLabel": "Nawigator promptów", + "session.prompts.tick": "Prompt {{index}} z {{total}}: {{prompt}}", + "session.prompts.noAnswer": "Jeszcze brak odpowiedzi", + "session.prompts.queued": "W kolejce", "session.status.writingResponse": "Pisanie odpowiedzi…", "session.status.retry": "Ponawianie…", "session.status.working": "Pracuję…", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index 649c5b84d17..75fa4a95df5 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -720,6 +720,10 @@ export const dict = { "session.messages.scrollToBottom": "Прокрутить вниз", "session.messages.initializing": "Инициализация...", "session.messages.taskStarting": "Запуск...", + "session.prompts.navLabel": "Навигатор промптов", + "session.prompts.tick": "Промпт {{index}} из {{total}}: {{prompt}}", + "session.prompts.noAnswer": "Ответа пока нет", + "session.prompts.queued": "В очереди", "session.status.writingResponse": "Пишу ответ…", "session.status.retry": "Повторная попытка…", "session.status.working": "Работаю…", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index a6f5e657eeb..15bd5d8cd16 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -712,6 +712,10 @@ export const dict = { "session.messages.scrollToBottom": "เลื่อนไปด้านล่าง", "session.messages.initializing": "กำลังเริ่มต้น...", "session.messages.taskStarting": "กำลังเริ่มทำงาน...", + "session.prompts.navLabel": "ตัวนำทางพรอมต์", + "session.prompts.tick": "พรอมต์ {{index}} จาก {{total}}: {{prompt}}", + "session.prompts.noAnswer": "ยังไม่มีการตอบกลับ", + "session.prompts.queued": "อยู่ในคิว", "session.status.writingResponse": "กำลังเขียนคำตอบ...", "session.status.retry": "กำลังลองใหม่…", "session.status.working": "กำลังทำงาน...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts index 9a9da245a62..5de84088d0c 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -707,6 +707,10 @@ export const dict = { "session.messages.scrollToBottom": "En alta kaydır", "session.messages.initializing": "Başlatılıyor...", "session.messages.taskStarting": "Başlıyor...", + "session.prompts.navLabel": "Komut gezgini", + "session.prompts.tick": "Komut {{index}} / {{total}}: {{prompt}}", + "session.prompts.noAnswer": "Henüz yanıt yok", + "session.prompts.queued": "Sırada", "session.status.writingResponse": "Yanıt yazılıyor...", "session.status.retry": "Yeniden deneniyor…", "session.status.working": "Çalışıyor...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts index f80fdf0f99a..585643f81c5 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -709,6 +709,10 @@ export const dict = { "session.messages.scrollToBottom": "Прокрутити до низу", "session.messages.initializing": "Ініціалізація...", "session.messages.taskStarting": "Запуск...", + "session.prompts.navLabel": "Навігатор запитів", + "session.prompts.tick": "Запит {{index}} з {{total}}: {{prompt}}", + "session.prompts.noAnswer": "Відповіді ще немає", + "session.prompts.queued": "У черзі", "session.status.writingResponse": "Пишу відповідь...", "session.status.retry": "Повторна спроба…", "session.status.working": "Працює...", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index ae31c74807d..69c4d6617f2 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -696,6 +696,10 @@ export const dict = { "session.messages.scrollToBottom": "滚动到底部", "session.messages.initializing": "初始化中...", "session.messages.taskStarting": "启动中...", + "session.prompts.navLabel": "提示词导航", + "session.prompts.tick": "提示词 {{index}}/{{total}}:{{prompt}}", + "session.prompts.noAnswer": "暂无响应", + "session.prompts.queued": "已排队", "session.status.writingResponse": "正在撰写回复…", "session.status.retry": "正在重试…", "session.status.working": "处理中…", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index 097657622df..51410df4219 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -656,6 +656,10 @@ export const dict = { "session.messages.scrollToBottom": "捲動至底部", "session.messages.initializing": "初始化中...", "session.messages.taskStarting": "啟動中...", + "session.prompts.navLabel": "提示詞導覽", + "session.prompts.tick": "提示詞 {{index}}/{{total}}:{{prompt}}", + "session.prompts.noAnswer": "尚無回應", + "session.prompts.queued": "已排入佇列", "session.status.writingResponse": "正在撰寫回覆…", "session.status.retry": "正在重試…", "session.status.working": "處理中…", diff --git a/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx index 7d5e6705e9a..4eb2cecdf56 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx @@ -617,6 +617,141 @@ export const ChatViewReadable420: Story = { render: () => renderReadableChat("busy"), } +// --------------------------------------------------------------------------- +// 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. +// --------------------------------------------------------------------------- + +const railNow = 1_700_000_200_000 +const railTurn = (i: number, prompt: string, answer: string | undefined, queued = false) => { + const userID = `rail-user-${i}` + const assistantID = `rail-asst-${i}` + const messages: any[] = [{ id: userID, sessionID: SESSION_ID, role: "user", time: { created: railNow + i * 100 } }] + if (!queued) { + messages.push({ + id: assistantID, + sessionID: SESSION_ID, + role: "assistant", + parentID: userID, + time: { created: railNow + i * 100 + 50 }, + modelID: "claude-sonnet-4-20250514", + providerID: "anthropic", + mode: "default", + agent: "default", + path: { cwd: "/project", root: "/project" }, + }) + } + const parts: Record = { + [userID]: [{ id: `rail-part-user-${i}`, sessionID: SESSION_ID, messageID: userID, type: "text", text: prompt }], + } + if (!queued) { + parts[assistantID] = answer + ? [{ id: `rail-part-asst-${i}`, sessionID: SESSION_ID, messageID: assistantID, type: "text", text: answer }] + : [ + { + id: `rail-part-asst-${i}`, + sessionID: SESSION_ID, + messageID: assistantID, + type: "tool", + callID: `rail-call-${i}`, + tool: "bash", + state: { + status: "completed", + input: { command: "ls", description: "List files" }, + output: "a.ts b.ts", + title: "ls", + metadata: {}, + time: { start: railNow + i * 100 + 50, end: railNow + i * 100 + 80 }, + }, + }, + ] + } + return { messages, parts } +} + +const railTurns = [ + railTurn( + 1, + "Add a prompt navigator rail to the left edge of the chat that expands into a card of prompt and answer previews when I hover it, without shrinking the readable lane", + "Added PromptRail with a tick per prompt and a hover card; the lane width is untouched.", + ), + railTurn(2, "yes", "Confirmed — wiring it into MessageList next."), + railTurn(3, "run the tests", undefined), + railTurn( + 4, + "now do the same in the Agent Manager chat", + "ChatView → MessageList is shared, so the rail appears there automatically; no Agent Manager specific code needed.", + ), + railTurn(5, "looks good, ship it", "", true), +] +const railMessages = railTurns.flatMap((turn) => turn.messages) +const railParts = Object.assign({}, ...railTurns.map((turn) => turn.parts)) +const railData = { + ...defaultMockData, + message: { [SESSION_ID]: railMessages }, + part: railParts, +} + +const renderRailChat = (status: "idle" | "busy" = "idle") => { + const session = { + ...mockSessionValue({ id: SESSION_ID, status }), + messages: () => railMessages, + userMessages: () => railMessages.filter((msg) => msg.role === "user"), + } + return ( + + +
+ +
+
+
+ ) +} + +export const PromptRailWide: Story = { + name: "PromptRail - wide editor tab", + render: () => renderRailChat(), +} + +export const PromptRailSidebar: Story = { + name: "PromptRail - narrow sidebar", + render: () => renderRailChat("busy"), +} + +// 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) => + railTurn(100 + i, `Prompt number ${i + 1} in a long running session`, `Answer number ${i + 1}.`), +) +const manyMessages = manyTurns.flatMap((turn) => turn.messages) +const manyData = { + ...defaultMockData, + message: { [SESSION_ID]: manyMessages }, + part: Object.assign({}, ...manyTurns.map((turn) => turn.parts)), +} + +export const PromptRailManyPrompts: Story = { + name: "PromptRail - long session caps to what fits", + render: () => { + const session = { + ...mockSessionValue({ id: SESSION_ID, status: "idle" }), + messages: () => manyMessages, + userMessages: () => manyMessages.filter((msg) => msg.role === "user"), + } + return ( + + +
+ +
+
+
+ ) + }, +} + export const MessageListToolToQueuedUserSpacing: Story = { name: "MessageList — queued users stay at bottom", render: () => { diff --git a/packages/kilo-vscode/webview-ui/src/styles/chat.css b/packages/kilo-vscode/webview-ui/src/styles/chat.css index 9bd9a1b1667..6f752069190 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/chat.css +++ b/packages/kilo-vscode/webview-ui/src/styles/chat.css @@ -10,6 +10,7 @@ @import "./search-menu.css"; @import "./session-tabs.css"; @import "./chat-layout.css"; +@import "./prompt-rail.css"; @import "./banners.css"; @import "./session-actions.css"; @import "./welcome.css"; diff --git a/packages/kilo-vscode/webview-ui/src/styles/prompt-rail.css b/packages/kilo-vscode/webview-ui/src/styles/prompt-rail.css new file mode 100644 index 00000000000..0e8392619d8 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/styles/prompt-rail.css @@ -0,0 +1,213 @@ +/* ============================================ + Prompt Rail + ============================================ */ + +.prompt-rail { + --prompt-rail-width: 16px; + --prompt-rail-gap: 8px; + --prompt-rail-step: 14px; + --prompt-rail-ease: cubic-bezier(0.22, 1, 0.36, 1); + + position: absolute; + inset-inline-start: max( + 0px, + calc(50% - var(--chat-readable-width) / 2 - var(--prompt-rail-width) - var(--prompt-rail-gap)) + ); + top: 0; + bottom: 0; + width: var(--prompt-rail-width); + display: flex; + flex-direction: column; + justify-content: center; + /* Guards the rail against ever painting outside its own column, whatever + the tick count or panel height. */ + overflow: hidden; + padding: 12px 0; + pointer-events: none; + z-index: 2; + opacity: 0.5; + transition: opacity 0.25s var(--prompt-rail-ease); +} + +.message-list-container:hover .prompt-rail, +.prompt-rail:focus-within { + opacity: 1; +} + +.prompt-rail-tick { + pointer-events: auto; + display: flex; + align-items: center; + justify-content: flex-start; + height: var(--prompt-rail-step); + min-height: 4px; + width: 100%; + border: none; + background: none; + padding: 0; + cursor: pointer; + /* No outline box: the focus ring is drawn on the line itself, so a focused + tick reads as part of the rail instead of a rectangle floating over it. */ + outline: none; +} + +.prompt-rail-tick-line { + display: block; + height: 1.5px; + width: 9px; + border-radius: 1px; + background: var(--icon-base); + opacity: 0.7; + transition: + width 0.24s var(--prompt-rail-ease), + opacity 0.2s var(--prompt-rail-ease), + background-color 0.2s var(--prompt-rail-ease), + box-shadow 0.2s var(--prompt-rail-ease); +} + +.prompt-rail-tick[data-queued] .prompt-rail-tick-line { + background: var(--icon-weaker); + opacity: 0.5; +} + +/* Scroll position: subtle, always-on cue. */ +.prompt-rail-tick--active .prompt-rail-tick-line { + width: 13px; + opacity: 1; + background: var(--icon-strong-base); +} + +/* Pointer or keyboard target: the loud state, full width. */ +.prompt-rail-tick:hover .prompt-rail-tick-line, +.prompt-rail-tick--open .prompt-rail-tick-line { + width: 100%; + height: 2px; + opacity: 1; + background: var(--icon-strong-base); +} + +.prompt-rail-tick:focus-visible .prompt-rail-tick-line { + width: 100%; + height: 2px; + opacity: 1; + background: var(--icon-strong-base); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--vscode-focusBorder) 50%, transparent); +} + +/* ============================================ + Prompt Rail card + ============================================ */ + +.prompt-rail-card { + position: fixed; + z-index: 1000; + box-sizing: border-box; + width: min(360px, calc(100vw - var(--prompt-rail-width, 16px) - 40px)); + max-height: calc(100vh - 24px); + overflow-y: auto; + padding: 6px; + border-radius: 12px; + /* Reads as floating glass rather than a panel pasted over the transcript: + the text behind stays faintly perceptible through the blur, so the card + feels like it belongs to the same surface. Falls back to an opaque fill + where backdrop-filter is unavailable. */ + background: color-mix(in srgb, var(--surface-float-base) 78%, transparent); + backdrop-filter: blur(20px) saturate(1.6); + -webkit-backdrop-filter: blur(20px) saturate(1.6); + box-shadow: + inset 0 0 0 1px color-mix(in srgb, var(--text-strong) 7%, transparent), + 0 2px 6px -2px rgba(0, 0, 0, 0.22), + 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; +} + +@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) { + .prompt-rail-card { + background: var(--surface-float-base); + } +} + +@keyframes prompt-rail-in { + from { + opacity: 0; + transform: translateX(-6px) scale(0.98); + } + to { + opacity: 1; + transform: translateX(0) scale(1); + } +} + +.prompt-rail-row { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + width: 100%; + box-sizing: border-box; + border: none; + background: none; + padding: 9px 11px; + border-radius: 8px; + text-align: start; + font: inherit; + cursor: pointer; + outline: none; + transition: background-color 0.16s var(--prompt-rail-ease, ease); +} + +.prompt-rail-row--hover, +.prompt-rail-row:hover { + background: color-mix(in srgb, var(--text-strong) 8%, transparent); +} + +.prompt-rail-row:active { + background: color-mix(in srgb, var(--text-strong) 12%, transparent); +} + +.prompt-rail-row-prompt { + font-size: var(--kilo-font-size-13); + line-height: var(--line-height-normal); + color: var(--text-strong); + font-weight: var(--font-weight-medium, 500); + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + min-width: 0; + width: 100%; +} + +.prompt-rail-row-prompt[data-queued] { + color: var(--text-weak); +} + +.prompt-rail-row-status { + color: var(--text-weaker); + font-weight: 400; +} + +.prompt-rail-row-answer { + font-size: var(--kilo-font-size-12); + line-height: var(--line-height-large, 1.5); + color: var(--text-weak); + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + min-width: 0; + width: 100%; +} + +@media (prefers-reduced-motion: reduce) { + .prompt-rail, + .prompt-rail-tick-line, + .prompt-rail-row { + transition: none; + } + .prompt-rail-card { + animation: none; + } +}