Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/timeline-bar-jump-to-message.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---

Click or press Enter/Space on a bar in the task timeline to jump the transcript to that message.
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,32 @@ export const MessageList: Component<MessageListProps> = (props) => {
const lookup = createMemo(() => new Map(partition().direct.map((row) => [row.key, row])))
const keys = createMemo(() => partition().virtual.map((row) => row.key))
const fingerprint = createMemo(() => rowFingerprint(keys()))

// 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
// threshold, which resumes auto-follow mid-animation and snaps back down.
const onScrollToMessage = (e: Event) => {
const detail = (e as CustomEvent<{ id: string; partId?: string }>).detail
if (!detail?.id) return
const matches = rows().filter((r) => r.type === "assistant" && r.message.id === detail.id)
// Long messages split into multiple rows (chunks); land on the chunk that
// 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<HTMLElement>(`[data-row-key="${CSS.escape(row.key)}"]`)
target?.scrollIntoView({ block: "start" })
}
window.addEventListener("scrollToMessage", onScrollToMessage)
onCleanup(() => window.removeEventListener("scrollToMessage", onScrollToMessage))

const measurement = createMemo(() => {
const id = session.currentSessionID()
const token = layout()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export interface TimelineBar {
width: number
height: number
idx: number
msgId: string
partId: string
}

function collect(messages: Message[], parts: Record<string, Part[]>): TimelineBar[] {
Expand All @@ -39,13 +41,16 @@ function collect(messages: Message[], parts: Record<string, Part[]>): TimelineBa
width: sz[i]!.width,
height: sz[i]!.height,
idx: i,
msgId: item.msg.id,
partId: item.part.id,
}))
}

export const TaskTimeline: Component = () => {
const session = useSession()
let ref: HTMLDivElement | undefined
let dragging = false
let dragMoved = false
let startX = 0
let startScroll = 0
const [hover, setHover] = createSignal(-1)
Expand Down Expand Up @@ -135,13 +140,21 @@ export const TaskTimeline: Component = () => {
hideTip()
if (!ref) return
dragging = true
dragMoved = false
startX = e.clientX
startScroll = ref.scrollLeft
ref.setPointerCapture(e.pointerId)
ref.style.cursor = "grabbing"
ref.style.userSelect = "none"
}

const jumpToMessage = (idx: number) => {
const bar = bars()[idx]
if (!bar) return
setActive(idx)
window.dispatchEvent(new CustomEvent("scrollToMessage", { detail: { id: bar.msgId, partId: bar.partId } }))
}

const onPointerMove = (e: PointerEvent) => {
if (!ref) return
if (!dragging) {
Expand All @@ -150,15 +163,18 @@ export const TaskTimeline: Component = () => {
if (idx < 0) return hideTip()
return showTip(idx)
}
if (Math.abs(e.clientX - startX) > 3) dragMoved = true
ref.scrollLeft = startScroll - (e.clientX - startX)
}

const onPointerUp = (e: PointerEvent) => {
if (!ref) return
const wasDragging = dragging
dragging = false
if (ref.hasPointerCapture(e.pointerId)) ref.releasePointerCapture(e.pointerId)
ref.style.cursor = "grab"
ref.style.userSelect = ""
if (wasDragging && !dragMoved) jumpToMessage(pointerIndex(e))
}

const onWheel = (e: WheelEvent) => {
Expand All @@ -169,6 +185,11 @@ export const TaskTimeline: Component = () => {
}

const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
jumpToMessage(selected())
return
}
if (!ref || !["ArrowLeft", "ArrowRight", "Home", "End"].includes(e.key)) return
e.preventDefault()
const idx = navigate(selected(), bars().length, e.key)
Expand Down
Loading