mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 11:05:31 +08:00
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:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Navigate long conversations from a compact prompt rail that loads earlier history as you scroll.
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:51adb9e31ce0bc82981b0f20f895ce4ede3f92828546115f929942ef93bf0812
|
||||
size 11204
|
||||
oid sha256:d7fc23fcb7adf483c0b771ef601b23cb365dc7f40033b00fce703b35910aa4fc
|
||||
size 27159
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c5658ed9e5266311c4239b7cd470d2f772d1cc0b3fd6c6b00337d4f3141a3a4d
|
||||
size 11950
|
||||
oid sha256:d11f4004ed3170647d385c14df077d235f5bb9bd6e5dec07557ee2014d553233
|
||||
size 27302
|
||||
|
||||
+2
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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))) {
|
||||
|
||||
Reference in New Issue
Block a user