fix(vscode): align timeline highlights with transcript

This commit is contained in:
marius-kilocode
2026-07-09 11:05:46 +02:00
parent 2040f6c896
commit 3eb47faaf0
8 changed files with 206 additions and 50 deletions
@@ -30,9 +30,23 @@ describe("TaskTimeline delegated tooltip contract", () => {
it("keeps accessibility and bar overlays bounded", () => {
expect(src).toMatch(/data-timeline-count=\{bars\(\)\.length\}/)
expect(src).toMatch(/tabIndex=\{0\}/)
expect(src).toMatch(/aria-label=\{aria\(\)\}/)
expect(src).toMatch(/role="slider"/)
expect(src).toMatch(/aria-valuenow=\{value\(\)\}/)
expect(src).toMatch(/aria-valuetext=\{aria\(\)\}/)
expect(src).toMatch(/<For each=\{layout\(\)\.paths\}>/)
expect(src).not.toMatch(/<Index\b/)
expect(src).not.toMatch(/data-tip=/)
})
it("only collects parts with matching transcript content", () => {
expect(src).toMatch(/const revert = session\.revert\(\) \?\? undefined/)
expect(src).toMatch(/visibleParts\(m\.id, session\.getParts\(m\.id\), revert\)/)
expect(src).toMatch(/isRenderable\(part as SDKPart, m as SDKAssistantMessage\)/)
expect(src).toMatch(/item\.tool\?\.callID === call && item\.tool\?\.messageID === m\.id/)
})
it("keeps selected bars highlighted after click and keyboard activation", () => {
expect(src).toMatch(/const select = \(idx: number\) => \{[\s\S]*showTip\(idx\)/)
expect(src).toMatch(/select\(selected\(\)\)/)
})
})
@@ -0,0 +1,55 @@
import { describe, expect, it } from "bun:test"
import path from "node:path"
const WEBVIEW = path.resolve(import.meta.dir, "../../webview-ui")
const PASS = "TIMELINE_HIGHLIGHT_EVENTS_PASS"
const FAIL = "TIMELINE_HIGHLIGHT_EVENTS_FAIL:"
const SCRIPT = `
import { Window } from "happy-dom"
const window = new Window()
globalThis.window = window
globalThis.CustomEvent = window.CustomEvent
const { dispatchTimelineHighlight, onTimelineHighlight } = await import("./src/utils/timeline/highlight.ts")
const values = []
const dispose = onTimelineHighlight((value) => values.push(value))
const value = { msgId: "message-1", partId: "part-1" }
dispatchTimelineHighlight(value)
dispose()
dispatchTimelineHighlight(undefined)
const fail = (reason) => {
console.log("${FAIL}" + reason)
process.exit(2)
}
if (values.length !== 1) fail("listener was not cleaned up")
if (values[0]?.msgId !== value.msgId || values[0]?.partId !== value.partId) {
fail("listener received the wrong highlight")
}
console.log("${PASS}")
`
describe("timeline highlight events", () => {
it("delivers a highlight once and removes its listener", () => {
const result = Bun.spawnSync(["bun", "--conditions=browser", "-e", SCRIPT], {
cwd: WEBVIEW,
stdout: "pipe",
stderr: "pipe",
})
const output = result.stdout.toString() + result.stderr.toString()
if (output.includes(PASS)) return
const index = output.indexOf(FAIL)
if (index !== -1) {
expect.unreachable(
output
.slice(index + FAIL.length)
.split("\n")[0]
?.trim(),
)
}
expect.unreachable(`timeline highlight events test exited ${result.exitCode}: ${output.trim()}`)
})
})
@@ -0,0 +1,58 @@
import { describe, expect, it } from "bun:test"
import path from "node:path"
const WEBVIEW = path.resolve(import.meta.dir, "../../webview-ui")
const PASS = "TRANSCRIPT_PARTS_PASS"
const FAIL = "TRANSCRIPT_PARTS_FAIL:"
const SCRIPT = `
import { Window } from "happy-dom"
const window = new Window()
globalThis.window = window
globalThis.document = window.document
globalThis.Node = window.Node
globalThis.CustomEvent = window.CustomEvent
const { isRenderable } = await import("./src/utils/transcript-parts.ts")
const message = { id: "message-1", role: "assistant", time: { created: 1, completed: 2 } }
const parts = [
{ id: "step-finish", type: "step-finish", reason: "stop" },
{ id: "empty-text", type: "text", text: " " },
{ id: "synthetic-text", type: "text", text: "Synthetic", synthetic: true },
{ id: "visible-text", type: "text", text: "Visible transcript text" },
]
const visible = parts.filter((part) => isRenderable(part, message)).map((part) => part.id)
const fail = (reason) => {
console.log("${FAIL}" + reason)
process.exit(2)
}
if (visible.length !== 1 || visible[0] !== "visible-text") {
fail("did not exclude transcript-invisible parts")
}
console.log("${PASS}")
`
describe("transcript parts", () => {
it("keeps timeline candidates aligned with visible transcript parts", () => {
const result = Bun.spawnSync(["bun", "--conditions=browser", "-e", SCRIPT], {
cwd: WEBVIEW,
stdout: "pipe",
stderr: "pipe",
})
const output = result.stdout.toString() + result.stderr.toString()
if (output.includes(PASS)) return
const index = output.indexOf(FAIL)
if (index !== -1) {
expect.unreachable(
output
.slice(index + FAIL.length)
.split("\n")[0]
?.trim(),
)
}
expect.unreachable(`transcript parts test exited ${result.exitCode}: ${output.trim()}`)
})
})
@@ -25,8 +25,8 @@ import { useConfig } from "../../context/config"
import { useLanguage } from "../../context/language"
import { useMemory } from "../../context/memory"
import { useServer } from "../../context/server"
import { snapshotProgress } from "../../context/session-utils"
import { planDisplayPath } from "../../utils/plan-path"
import { isRenderable, UPSTREAM_SUPPRESSED_TOOLS } from "../../utils/transcript-parts"
import { MemoryMarkerMeta } from "@kilocode/kilo-memory/marker-meta"
import { color as timelineColor } from "../../utils/timeline/colors"
import type { Part as TimelinePart } from "../../types/messages"
@@ -34,10 +34,6 @@ import type { TimelineHighlight } from "../../utils/timeline/highlight"
import { QuestionDock } from "./QuestionDock"
import { SuggestBar } from "./SuggestBar"
// Tools that the upstream message-part renderer suppresses (returns null for).
// We render these ourselves via ToolRegistry when they complete,
// so the user can see what the AI set up.
export const UPSTREAM_SUPPRESSED_TOOLS = new Set(["todowrite", "todoread"])
const EDIT_TOOLS = new Set(["edit", "write", "apply_patch"])
function editOpen(part: SDKPart, open: boolean) {
@@ -90,24 +86,6 @@ function PlanExitCard(props: { part: ToolPart }) {
)
}
function isRenderable(part: SDKPart): boolean {
if (part.type === "tool") {
const tool = (part as SDKPart & { tool: string }).tool
const state = (part as SDKPart & { state: { status: string } }).state
if (UPSTREAM_SUPPRESSED_TOOLS.has(tool)) {
// Show completed todo parts only when kilo-ui provides a visible renderer.
return state.status === "completed" && !!ToolRegistry.render(tool)
}
// Always render question tool parts — active ones get the inline QuestionDock
return true
}
if (part.type === "text") return !snapshotProgress(part) && !!(part as SDKPart & { text: string }).text?.trim()
if (part.type === "reasoning") {
return !!(part as SDKPart & { text: string }).text?.replace("[REDACTED]", "").trim()
}
return !!PART_MAPPING[part.type]
}
/**
* Match a tool part to an active request (question or suggestion) by tool name
* and callID/messageID. Returns the matched request or undefined.
@@ -203,8 +181,7 @@ export const AssistantMessage: Component<AssistantMessageProps> = (props) => {
const stored = props.parts ?? data.store.part?.[props.message.id]
if (!stored) return []
return (stored as SDKPart[]).filter((part) => {
if (!isRenderable(part)) return false
if (part.type === "text" && part.synthetic && props.message.time.completed) return false
if (!isRenderable(part, props.message)) return false
if (part.type !== "tool" || part.tool !== "question") return true
if (part.state.status !== "pending" && part.state.status !== "running") return true
return !!matchToolRequest(part, "question", session.questions())
@@ -1,3 +1,4 @@
/** @jsxImportSource solid-js */
/**
* Horizontal session activity timeline rendered as color-grouped SVG paths.
* Pointer and keyboard interaction use the same pure bar geometry.
@@ -5,11 +6,14 @@
import { Component, For, Show, createMemo, createEffect, createSignal, on, onCleanup } from "solid-js"
import { Portal } from "solid-js/web"
import type { AssistantMessage as SDKAssistantMessage, Part as SDKPart } from "@kilocode/sdk/v2"
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 { sizes, pinned, MAX_HEIGHT } from "../../utils/timeline/sizes"
import { isRenderable } from "../../utils/transcript-parts"
import type { Part, Message } from "../../types/messages"
export interface TimelineBar {
@@ -61,9 +65,18 @@ export const TaskTimeline: Component = () => {
const messages = () => session.visibleMessages()
const allParts = () => {
const msgs = messages()
const revert = session.revert() ?? undefined
const qs = session.questions()
const result: Record<string, Part[]> = {}
for (const m of msgs) {
const p = session.getParts(m.id)
if (m.role === "user") continue
const p = visibleParts(m.id, session.getParts(m.id), revert).filter((part) => {
if (!isRenderable(part as SDKPart, m as SDKAssistantMessage)) return false
if (part.type !== "tool" || part.tool !== "question") return true
if (part.state.status !== "pending" && part.state.status !== "running") return true
const call = (part as SDKPart & { callID: string }).callID
return qs.some((item) => item.tool?.callID === call && item.tool?.messageID === m.id)
})
if (p.length > 0) result[m.id] = p
}
return result
@@ -80,9 +93,10 @@ export const TaskTimeline: Component = () => {
const aria = () => {
const idx = selected()
const bar = bars()[idx]
if (!bar) return "Session activity timeline, no activity"
return `Session activity timeline, bar ${idx + 1} of ${bars().length}: ${bar.tip}`
if (!bar) return "No activity"
return `Bar ${idx + 1} of ${bars().length}: ${bar.tip}`
}
const value = () => Math.max(0, selected() + 1)
let prev = 0
let frame: number | undefined
@@ -158,11 +172,12 @@ export const TaskTimeline: Component = () => {
ref.style.userSelect = "none"
}
const jumpToMessage = (idx: number) => {
const select = (idx: number) => {
const bar = bars()[idx]
if (!bar) return
setActive(idx)
window.dispatchEvent(new CustomEvent("scrollToMessage", { detail: { id: bar.msgId, partId: bar.partId } }))
showTip(idx)
}
const onPointerMove = (e: PointerEvent) => {
@@ -186,10 +201,7 @@ export const TaskTimeline: Component = () => {
ref.style.userSelect = ""
if (!wasDragging || dragMoved) return
const idx = pointerIndex(e)
jumpToMessage(idx)
// onPointerDown hid the tip pre-emptively in case this turned into a
// drag; restore it for the clicked bar since the pointer is still on it.
showTip(idx)
select(idx)
}
const onWheel = (e: WheelEvent) => {
@@ -202,7 +214,7 @@ export const TaskTimeline: Component = () => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
jumpToMessage(selected())
select(selected())
return
}
if (!ref || !["ArrowLeft", "ArrowRight", "Home", "End"].includes(e.key)) return
@@ -250,9 +262,14 @@ export const TaskTimeline: Component = () => {
ref={ref}
class="task-timeline"
data-timeline-count={bars().length}
role="img"
role="slider"
tabIndex={0}
aria-label={aria()}
aria-label="Session activity timeline"
aria-description="Use arrow keys to choose activity, then press Enter to open it in the transcript."
aria-valuemin={bars().length > 0 ? 1 : 0}
aria-valuemax={bars().length}
aria-valuenow={value()}
aria-valuetext={aria()}
style={{ height: `${MAX_HEIGHT}px` }}
onKeyDown={onKeyDown}
onBlur={hideTip}
@@ -646,6 +646,25 @@ export const ToolCards: Story = {
},
}
export const TimelineHighlightedTool: Story = {
name: "Task Timeline — highlighted tool",
render: () => {
const data = dataWith([readCompleted])
return (
<StoryProviders data={data} sessionID={SESSION_ID}>
<div class="vscode-session-turn" data-row="assistant">
<div class="vscode-session-turn-assistant">
<AssistantMessage
message={baseAssistantMessage}
highlight={() => ({ msgId: ASST_MSG_ID, partId: readCompleted.id })}
/>
</div>
</div>
</StoryProviders>
)
},
}
export const BackgroundProcessToolCards: Story = {
name: "Tool Cards — background process",
render: () => {
@@ -226,11 +226,6 @@
flex-direction: column;
gap: 6px;
width: 100%;
/* Reserves extra room, on top of the turn's own 4px padding, for the
task-timeline highlight strip (see [data-component="tool-part-wrapper"]
below) to sit further from the card edge with a visible gap. Scoped to
assistant content only so user bubbles/diffs stay unaffected. */
padding-left: 4px;
}
[data-component="assistant-memory-badge"] {
@@ -266,9 +261,7 @@
An absolutely positioned element always paints above normal-flow
(position: static) children, so it stays visible without needing to
extend past the wrapper's own box — virtua's row virtualizer clips its
content to exactly that box (`overflow: clip`), so anything drawn outside
it (e.g. a negative left offset) gets clipped entirely once rows are
virtualized. */
content to exactly that box (`overflow: clip`). */
[data-component="tool-part-wrapper"] {
position: relative;
}
@@ -278,11 +271,9 @@
position: absolute;
top: 2px;
bottom: 2px;
/* .vscode-session-turn-assistant's extra 4px padding-left (see above) plus
the turn's own 4px gives 8px of gutter here before virtua's clip edge.
Sit 1px inside that edge for safety, leaving a clear gap before the card
(which starts at 0). */
left: -7px;
/* Use the turn's existing 4px inset: the 3px strip stays within the
virtualized row and leaves a 1px gap before the card. */
left: -4px;
width: 3px;
border-radius: 2px 0 0 2px;
background: var(--timeline-color, transparent);
@@ -296,6 +287,12 @@
opacity: 0.9;
}
@media (prefers-reduced-motion: reduce) {
[data-component="tool-part-wrapper"]::before {
transition: none;
}
}
.chat-view .message-list-content > .revert-banner,
.chat-view .message-list-content > [data-component="question-dock"],
.chat-view .message-list-content > .working-indicator-slot,
@@ -0,0 +1,19 @@
import { PART_MAPPING, ToolRegistry } from "@kilocode/kilo-ui/message-part"
import type { AssistantMessage, Part } from "@kilocode/sdk/v2"
import { snapshotProgress } from "../context/session-utils"
export const UPSTREAM_SUPPRESSED_TOOLS = new Set(["todowrite", "todoread"])
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)
}
return true
}
if (part.type === "text") {
return !snapshotProgress(part) && !!part.text?.trim() && !(part.synthetic && message?.time.completed)
}
if (part.type === "reasoning") return !!part.text?.replace("[REDACTED]", "").trim()
return !!PART_MAPPING[part.type]
}