mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
fix(vscode): align timeline highlights with transcript
This commit is contained in:
@@ -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]
|
||||
}
|
||||
Reference in New Issue
Block a user