From 3eb47faaf083f3cf58988d7e715873eed72e07af Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 9 Jul 2026 11:05:46 +0200 Subject: [PATCH] fix(vscode): align timeline highlights with transcript --- .../tests/unit/task-timeline-tooltip.test.ts | 16 ++++- .../unit/timeline-highlight-events.test.ts | 55 ++++++++++++++++++ .../tests/unit/transcript-parts.test.ts | 58 +++++++++++++++++++ .../src/components/chat/AssistantMessage.tsx | 27 +-------- .../src/components/chat/TaskTimeline.tsx | 39 +++++++++---- .../src/stories/composite.stories.tsx | 19 ++++++ .../webview-ui/src/styles/chat-layout.css | 23 ++++---- .../webview-ui/src/utils/transcript-parts.ts | 19 ++++++ 8 files changed, 206 insertions(+), 50 deletions(-) create mode 100644 packages/kilo-vscode/tests/unit/timeline-highlight-events.test.ts create mode 100644 packages/kilo-vscode/tests/unit/transcript-parts.test.ts create mode 100644 packages/kilo-vscode/webview-ui/src/utils/transcript-parts.ts 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 d718585288..5c16ba5211 100644 --- a/packages/kilo-vscode/tests/unit/task-timeline-tooltip.test.ts +++ b/packages/kilo-vscode/tests/unit/task-timeline-tooltip.test.ts @@ -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(//) expect(src).not.toMatch(/ { + 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\(\)\)/) + }) }) diff --git a/packages/kilo-vscode/tests/unit/timeline-highlight-events.test.ts b/packages/kilo-vscode/tests/unit/timeline-highlight-events.test.ts new file mode 100644 index 0000000000..1b8a5f5e54 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/timeline-highlight-events.test.ts @@ -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()}`) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/transcript-parts.test.ts b/packages/kilo-vscode/tests/unit/transcript-parts.test.ts new file mode 100644 index 0000000000..71a36b1e7e --- /dev/null +++ b/packages/kilo-vscode/tests/unit/transcript-parts.test.ts @@ -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()}`) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx index 6d81c65cde..491f469f74 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx @@ -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 = (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()) 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 03fbf75953..4c53f8e0dc 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/TaskTimeline.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/TaskTimeline.tsx @@ -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 = {} 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} diff --git a/packages/kilo-vscode/webview-ui/src/stories/composite.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/composite.stories.tsx index 7a7bb90403..bcab3a6c58 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/composite.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/composite.stories.tsx @@ -646,6 +646,25 @@ export const ToolCards: Story = { }, } +export const TimelineHighlightedTool: Story = { + name: "Task Timeline — highlighted tool", + render: () => { + const data = dataWith([readCompleted]) + return ( + +
+
+ ({ msgId: ASST_MSG_ID, partId: readCompleted.id })} + /> +
+
+
+ ) + }, +} + export const BackgroundProcessToolCards: Story = { name: "Tool Cards — background process", render: () => { diff --git a/packages/kilo-vscode/webview-ui/src/styles/chat-layout.css b/packages/kilo-vscode/webview-ui/src/styles/chat-layout.css index fe718b7eab..54097de682 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/chat-layout.css +++ b/packages/kilo-vscode/webview-ui/src/styles/chat-layout.css @@ -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, diff --git a/packages/kilo-vscode/webview-ui/src/utils/transcript-parts.ts b/packages/kilo-vscode/webview-ui/src/utils/transcript-parts.ts new file mode 100644 index 0000000000..f55a087dd7 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/utils/transcript-parts.ts @@ -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] +}