diff --git a/.changeset/timeline-bar-jump-to-message.md b/.changeset/timeline-bar-jump-to-message.md new file mode 100644 index 00000000000..51dcb43a74a --- /dev/null +++ b/.changeset/timeline-bar-jump-to-message.md @@ -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. 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 5913ef4f366..a5c78cd5e1f 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx @@ -157,6 +157,32 @@ export const MessageList: Component = (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(`[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() diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/TaskTimeline.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/TaskTimeline.tsx index aa2975d8b61..5bcb351de14 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/TaskTimeline.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/TaskTimeline.tsx @@ -17,6 +17,8 @@ export interface TimelineBar { width: number height: number idx: number + msgId: string + partId: string } function collect(messages: Message[], parts: Record): TimelineBar[] { @@ -39,6 +41,8 @@ function collect(messages: Message[], parts: Record): TimelineBa width: sz[i]!.width, height: sz[i]!.height, idx: i, + msgId: item.msg.id, + partId: item.part.id, })) } @@ -46,6 +50,7 @@ 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) @@ -135,6 +140,7 @@ export const TaskTimeline: Component = () => { hideTip() if (!ref) return dragging = true + dragMoved = false startX = e.clientX startScroll = ref.scrollLeft ref.setPointerCapture(e.pointerId) @@ -142,6 +148,13 @@ export const TaskTimeline: Component = () => { 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) { @@ -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) => { @@ -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)