fix(vscode): preserve timeline highlight while streaming

This commit is contained in:
marius-kilocode
2026-07-09 13:50:28 +02:00
parent 961b65b8bd
commit c8dec4187c
6 changed files with 44 additions and 8 deletions
@@ -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/)
})
})
@@ -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}")
`
@@ -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}")
@@ -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<TimelineHighlight | undefined>((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()}
@@ -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) {
@@ -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)