From c8dec4187ce318a6ea0fa5d4832230e0ab3704ca Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 9 Jul 2026 13:50:28 +0200 Subject: [PATCH] fix(vscode): preserve timeline highlight while streaming --- .../tests/unit/task-timeline-tooltip.test.ts | 5 ++++ .../unit/timeline-highlight-events.test.ts | 5 +++- .../tests/unit/transcript-parts.test.ts | 13 ++++++++++- .../src/components/chat/TaskTimeline.tsx | 23 +++++++++++++++---- .../src/utils/timeline/highlight.ts | 4 ++++ .../webview-ui/src/utils/transcript-parts.ts | 2 +- 6 files changed, 44 insertions(+), 8 deletions(-) diff --git a/packages/kilo-vscode/tests/unit/task-timeline-tooltip.test.ts b/packages/kilo-vscode/tests/unit/task-timeline-tooltip.test.ts index 5c16ba5211..7f994e81bd 100644 --- a/packages/kilo-vscode/tests/unit/task-timeline-tooltip.test.ts +++ b/packages/kilo-vscode/tests/unit/task-timeline-tooltip.test.ts @@ -49,4 +49,9 @@ describe("TaskTimeline delegated tooltip contract", () => { expect(src).toMatch(/const select = \(idx: number\) => \{[\s\S]*showTip\(idx\)/) expect(src).toMatch(/select\(selected\(\)\)/) }) + + it("preserves a hovered part across streaming updates", () => { + expect(src).toMatch(/if \(idx < 0 \|\| same\(previous\?\.\[idx\], next\[idx\]\)\) return/) + expect(src).toMatch(/if \(same\(previous, next\)\) return previous/) + }) }) diff --git a/packages/kilo-vscode/tests/unit/timeline-highlight-events.test.ts b/packages/kilo-vscode/tests/unit/timeline-highlight-events.test.ts index 1b8a5f5e54..b34f5cfef5 100644 --- a/packages/kilo-vscode/tests/unit/timeline-highlight-events.test.ts +++ b/packages/kilo-vscode/tests/unit/timeline-highlight-events.test.ts @@ -12,7 +12,7 @@ const SCRIPT = ` globalThis.window = window globalThis.CustomEvent = window.CustomEvent - const { dispatchTimelineHighlight, onTimelineHighlight } = await import("./src/utils/timeline/highlight.ts") + const { dispatchTimelineHighlight, onTimelineHighlight, same } = await import("./src/utils/timeline/highlight.ts") const values = [] const dispose = onTimelineHighlight((value) => values.push(value)) const value = { msgId: "message-1", partId: "part-1" } @@ -28,6 +28,9 @@ const SCRIPT = ` if (values[0]?.msgId !== value.msgId || values[0]?.partId !== value.partId) { fail("listener received the wrong highlight") } + if (!same(value, { ...value }) || same(value, { ...value, partId: "part-2" })) { + fail("highlight identity comparison is incorrect") + } console.log("${PASS}") ` diff --git a/packages/kilo-vscode/tests/unit/transcript-parts.test.ts b/packages/kilo-vscode/tests/unit/transcript-parts.test.ts index 71a36b1e7e..351fa3a999 100644 --- a/packages/kilo-vscode/tests/unit/transcript-parts.test.ts +++ b/packages/kilo-vscode/tests/unit/transcript-parts.test.ts @@ -21,6 +21,16 @@ const SCRIPT = ` { id: "empty-text", type: "text", text: " " }, { id: "synthetic-text", type: "text", text: "Synthetic", synthetic: true }, { id: "visible-text", type: "text", text: "Visible transcript text" }, + { id: "redacted-reasoning", type: "reasoning", text: "[REDACTED]" }, + { id: "visible-reasoning", type: "reasoning", text: "Inspect the implementation" }, + { id: "todo-pending", type: "tool", tool: "todowrite", state: { status: "pending", input: {} } }, + { + id: "todo-completed", + type: "tool", + tool: "todowrite", + state: { status: "completed", input: {}, output: "done", title: "Updated todos" }, + }, + { id: "read-running", type: "tool", tool: "read", state: { status: "running", input: {} } }, ] const visible = parts.filter((part) => isRenderable(part, message)).map((part) => part.id) @@ -28,7 +38,8 @@ const SCRIPT = ` console.log("${FAIL}" + reason) process.exit(2) } - if (visible.length !== 1 || visible[0] !== "visible-text") { + const expected = ["visible-text", "visible-reasoning", "todo-completed", "read-running"] + if (visible.length !== expected.length || visible.some((id, index) => id !== expected[index])) { fail("did not exclude transcript-invisible parts") } console.log("${PASS}") 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 4c53f8e0dc..a735eb8bc8 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/TaskTimeline.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/TaskTimeline.tsx @@ -11,7 +11,7 @@ import { useSession } from "../../context/session" import { visibleParts } from "../../context/session-queue" import { color, label } from "../../utils/timeline/colors" import { geometry, hit, navigate } from "../../utils/timeline/geometry" -import { dispatchTimelineHighlight } from "../../utils/timeline/highlight" +import { dispatchTimelineHighlight, same, type TimelineHighlight } from "../../utils/timeline/highlight" import { sizes, pinned, MAX_HEIGHT } from "../../utils/timeline/sizes" import { isRenderable } from "../../utils/transcript-parts" import type { Part, Message } from "../../types/messages" @@ -129,14 +129,27 @@ export const TaskTimeline: Component = () => { setTip(undefined) } - createEffect(on(bars, hideTip, { defer: true })) + createEffect( + on( + bars, + (next, previous) => { + const idx = hover() + if (idx < 0 || same(previous?.[idx], next[idx])) return + hideTip() + }, + { defer: true }, + ), + ) // Highlight the chat part behind the hovered/focused bar, using its own // color, so it's easy to follow which bar belongs to which tool call. - createEffect(() => { + createEffect((previous) => { const idx = hover() const bar = idx >= 0 ? bars()[idx] : undefined - dispatchTimelineHighlight(bar ? { msgId: bar.msgId, partId: bar.partId } : undefined) + const next = bar ? { msgId: bar.msgId, partId: bar.partId } : undefined + if (same(previous, next)) return previous + dispatchTimelineHighlight(next) + return next }) onCleanup(() => dispatchTimelineHighlight(undefined)) @@ -265,7 +278,7 @@ export const TaskTimeline: Component = () => { role="slider" tabIndex={0} aria-label="Session activity timeline" - aria-description="Use arrow keys to choose activity, then press Enter to open it in the transcript." + aria-keyshortcuts="ArrowLeft ArrowRight Home End Enter Space" aria-valuemin={bars().length > 0 ? 1 : 0} aria-valuemax={bars().length} aria-valuenow={value()} diff --git a/packages/kilo-vscode/webview-ui/src/utils/timeline/highlight.ts b/packages/kilo-vscode/webview-ui/src/utils/timeline/highlight.ts index 7d73ae22fb..1859589781 100644 --- a/packages/kilo-vscode/webview-ui/src/utils/timeline/highlight.ts +++ b/packages/kilo-vscode/webview-ui/src/utils/timeline/highlight.ts @@ -13,6 +13,10 @@ export interface TimelineHighlight { partId: string } +export function same(a: TimelineHighlight | undefined, b: TimelineHighlight | undefined) { + return a?.msgId === b?.msgId && a?.partId === b?.partId +} + const EVENT = "timelineHighlight" export function dispatchTimelineHighlight(value: TimelineHighlight | undefined) { diff --git a/packages/kilo-vscode/webview-ui/src/utils/transcript-parts.ts b/packages/kilo-vscode/webview-ui/src/utils/transcript-parts.ts index f55a087dd7..2a6919002b 100644 --- a/packages/kilo-vscode/webview-ui/src/utils/transcript-parts.ts +++ b/packages/kilo-vscode/webview-ui/src/utils/transcript-parts.ts @@ -4,7 +4,7 @@ import { snapshotProgress } from "../context/session-utils" export const UPSTREAM_SUPPRESSED_TOOLS = new Set(["todowrite", "todoread"]) -export function isRenderable(part: Part, message?: AssistantMessage): boolean { +export function isRenderable(part: Part, message: AssistantMessage): boolean { if (part.type === "tool") { if (UPSTREAM_SUPPRESSED_TOOLS.has(part.tool)) { return part.state.status === "completed" && !!ToolRegistry.render(part.tool)