From 4acc1f8bcccc48f01fc046d799f5546bff247407 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Wed, 10 Jun 2026 21:47:13 +0200 Subject: [PATCH 1/3] adopt row based virtualization --- .changeset/fast-agent-transcripts.md | 5 + .../tests/unit/task-timeline-tooltip.test.ts | 18 +- .../tests/unit/timeline-geometry.test.ts | 46 ++++ .../tests/unit/transcript-cache.test.ts | 86 +++++++ .../tests/unit/transcript-rows.test.ts | 227 ++++++++++++++++++ .../src/components/chat/AssistantMessage.tsx | 3 +- .../src/components/chat/MessageList.tsx | 160 ++++++++---- .../src/components/chat/TaskTimeline.tsx | 154 +++++++----- .../src/components/chat/TranscriptRow.tsx | 131 ++++++++++ .../src/components/chat/transcript-cache.ts | 85 +++++++ .../webview-ui/src/context/transcript-rows.ts | 196 +++++++++++++++ .../webview-ui/src/styles/task-header.css | 50 ++-- .../webview-ui/src/utils/timeline/geometry.ts | 73 ++++++ 13 files changed, 1085 insertions(+), 149 deletions(-) create mode 100644 .changeset/fast-agent-transcripts.md create mode 100644 packages/kilo-vscode/tests/unit/timeline-geometry.test.ts create mode 100644 packages/kilo-vscode/tests/unit/transcript-cache.test.ts create mode 100644 packages/kilo-vscode/tests/unit/transcript-rows.test.ts create mode 100644 packages/kilo-vscode/webview-ui/src/components/chat/TranscriptRow.tsx create mode 100644 packages/kilo-vscode/webview-ui/src/components/chat/transcript-cache.ts create mode 100644 packages/kilo-vscode/webview-ui/src/context/transcript-rows.ts create mode 100644 packages/kilo-vscode/webview-ui/src/utils/timeline/geometry.ts diff --git a/.changeset/fast-agent-transcripts.md b/.changeset/fast-agent-transcripts.md new file mode 100644 index 00000000000..be0d8f1928b --- /dev/null +++ b/.changeset/fast-agent-transcripts.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Keep large Agent Manager transcripts responsive by mounting only viewport-visible conversation rows. 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 4d2115af1d6..d7185852886 100644 --- a/packages/kilo-vscode/tests/unit/task-timeline-tooltip.test.ts +++ b/packages/kilo-vscode/tests/unit/task-timeline-tooltip.test.ts @@ -19,12 +19,20 @@ describe("TaskTimeline delegated tooltip contract", () => { expect(src).not.toMatch(/ { - expect(src).toMatch(/data-tip=\{bar\(\)\.tip\}/) - expect(src).toMatch(/role="img"/) - expect(src).toMatch(/aria-label=\{bar\(\)\.tip\}/) - expect(src).toMatch(/if \(!bar \|\| !ref\?\.contains\(bar\)\) return hideTip\(\)/) + it("delegates SVG hit testing to one portal tooltip", () => { + expect(src).toMatch(/hit\(layout\(\)\.items, e\.clientX - rect\.left \+ ref\.scrollLeft\)/) + expect(src).toMatch(/const bar = bars\(\)\[idx\]/) + expect(src).toMatch(/text: bar\.tip/) expect(src).toMatch(//) expect(src).toMatch(/class="task-timeline-tooltip"/) }) + + 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(//) + expect(src).not.toMatch(/ { + const bars = [ + { bg: "blue", width: 3, height: 4 }, + { bg: "red", width: 5, height: 8 }, + { bg: "blue", width: 2, height: 6 }, + ] + + it("preserves visual positions while grouping paths by color", () => { + const result = geometry(bars, 10) + + expect(result.width).toBe(13) + expect(result.items.map((item) => [item.idx, item.x])).toEqual([ + [0, 0], + [1, 4], + [2, 10], + ]) + expect(result.paths).toHaveLength(2) + expect(result.paths.map((path) => path.bg)).toEqual(["blue", "red"]) + expect(result.paths[0]!.d).toContain("M0,10") + expect(result.paths[0]!.d).toContain("M10,10") + expect(result.paths[1]!.d).toContain("M4,10") + }) + + it("hit tests bars but not their gaps", () => { + const items = geometry(bars, 10).items + + expect(hit(items, 0)).toBe(0) + expect(hit(items, 2.99)).toBe(0) + expect(hit(items, 3)).toBe(-1) + expect(hit(items, 4)).toBe(1) + expect(hit(items, 12)).toBe(-1) + }) + + it("navigates in visual order with bounded endpoints", () => { + expect(navigate(-1, 3, "ArrowRight")).toBe(0) + expect(navigate(1, 3, "ArrowLeft")).toBe(0) + expect(navigate(2, 3, "ArrowRight")).toBe(2) + expect(navigate(1, 3, "Home")).toBe(0) + expect(navigate(1, 3, "End")).toBe(2) + expect(navigate(1, 3, "Escape")).toBe(1) + expect(navigate(0, 0, "ArrowRight")).toBe(-1) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/transcript-cache.test.ts b/packages/kilo-vscode/tests/unit/transcript-cache.test.ts new file mode 100644 index 00000000000..1a60a893ce9 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/transcript-cache.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it } from "bun:test" +import type { CacheSnapshot } from "virtua" +import { + getMeasurement, + getScroll, + layoutFingerprint, + resetTranscriptCaches, + resolveAnchor, + rowFingerprint, + setMeasurement, + setScroll, +} from "../../webview-ui/src/components/chat/transcript-cache" + +const snapshot = (id: number) => ({ id }) as unknown as CacheSnapshot +const layout = layoutFingerprint({ width: 800, ratio: 2, font: "Kilo Sans", size: "13px", line: "20px" }) + +describe("transcript measurement cache", () => { + beforeEach(resetTranscriptCaches) + + it("returns a cache only for the exact row and layout fingerprints", () => { + const keys = rowFingerprint(["a", "bc"]) + const cache = snapshot(1) + setMeasurement("session", keys, layout, cache) + + expect(getMeasurement("session", keys, layout)).toBe(cache) + expect(getMeasurement("session", rowFingerprint(["a", "bd"]), layout)).toBeUndefined() + expect(getMeasurement("session", keys, layout)).toBeUndefined() + }) + + it("invalidates a measurement when layout changes", () => { + const keys = rowFingerprint(["row"]) + setMeasurement("session", keys, layout, snapshot(1)) + const changed = layoutFingerprint({ width: 801, ratio: 2, font: "Kilo Sans", size: "13px", line: "20px" }) + + expect(getMeasurement("session", keys, changed)).toBeUndefined() + expect(getMeasurement("session", keys, layout)).toBeUndefined() + }) + + it("uses collision-safe row and layout fingerprints", () => { + expect(rowFingerprint(["a|b", "c"])).not.toBe(rowFingerprint(["a", "b|c"])) + expect(layoutFingerprint({ width: 80, ratio: 1, font: "a|b", size: "c", line: "d" })).not.toBe( + layoutFingerprint({ width: 80, ratio: 1, font: "a", size: "b|c", line: "d" }), + ) + }) + + it("evicts the least recently used measurement after 16 sessions", () => { + const keys = rowFingerprint(["row"]) + for (let i = 0; i < 16; i += 1) setMeasurement(`s${i}`, keys, layout, snapshot(i)) + expect(getMeasurement("s0", keys, layout)).toBeDefined() + setMeasurement("s16", keys, layout, snapshot(16)) + + expect(getMeasurement("s1", keys, layout)).toBeUndefined() + expect(getMeasurement("s0", keys, layout)).toBeDefined() + }) +}) + +describe("transcript scroll cache", () => { + beforeEach(resetTranscriptCaches) + + it("stores bottom-follow and anchored positions independently from measurements", () => { + setMeasurement("a", rowFingerprint(["row"]), layout, snapshot(1)) + setScroll("a", { type: "bottom" }) + setScroll("b", { type: "anchor", key: "row-2", offset: 37 }) + + expect(getScroll("a")).toEqual({ type: "bottom" }) + expect(getScroll("b")).toEqual({ type: "anchor", key: "row-2", offset: 37 }) + }) + + it("resolves an anchor after prepended rows shift its index", () => { + const state = { type: "anchor", key: "stable", offset: 19 } as const + expect(resolveAnchor(state, ["stable", "tail"])).toEqual({ index: 0, offset: 19 }) + expect(resolveAnchor(state, ["older-1", "older-2", "stable", "tail"])).toEqual({ index: 2, offset: 19 }) + expect(resolveAnchor(state, ["other"])).toBeUndefined() + expect(resolveAnchor({ type: "bottom" }, ["stable"])).toBeUndefined() + }) + + it("evicts the least recently used scroll state after 50 sessions", () => { + for (let i = 0; i < 50; i += 1) setScroll(`s${i}`, { type: "bottom" }) + expect(getScroll("s0")).toEqual({ type: "bottom" }) + setScroll("s50", { type: "anchor", key: "row", offset: 4 }) + + expect(getScroll("s1")).toBeUndefined() + expect(getScroll("s0")).toEqual({ type: "bottom" }) + expect(getScroll("s50")).toEqual({ type: "anchor", key: "row", offset: 4 }) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/transcript-rows.test.ts b/packages/kilo-vscode/tests/unit/transcript-rows.test.ts new file mode 100644 index 00000000000..f983df98fe9 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/transcript-rows.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, it } from "bun:test" +import { messageTurns } from "../../webview-ui/src/context/session-queue" +import { partitionRows, transcriptRows } from "../../webview-ui/src/context/transcript-rows" +import type { Message, Part } from "../../webview-ui/src/types/messages" + +const base = { + sessionID: "session", + createdAt: "2026-01-01T00:00:00.000Z", + time: { created: 1 }, +} + +const user = (id: string, opts: Partial = {}): Message => ({ ...base, id, role: "user", ...opts }) +const assistant = (id: string, parentID: string, opts: Partial = {}): Message => ({ + ...base, + id, + parentID, + role: "assistant", + ...opts, +}) +const part = (id: string, messageID: string): Part => ({ id, messageID, type: "text", text: id }) +const lookup = (values: Record) => (id: string) => values[id] ?? [] + +describe("transcriptRows", () => { + it("preserves turn order across user, bounded assistant, diff, and error rows", () => { + const u1 = user("u1", { summary: { diffs: [{ file: "a.ts" }] } }) + const a1 = assistant("a1", "u1") + const a2 = assistant("a2", "u1", { error: { name: "ProviderError" } }) + const u2 = user("u2") + const a3 = assistant("a3", "u2") + const parts = { + u1: [part("up1", "u1")], + a1: Array.from({ length: 10 }, (_, i) => part(`p${i}`, "a1")), + a2: [part("p10", "a2")], + a3: [part("p11", "a3")], + } + + const rows = transcriptRows(messageTurns([u1, a1, a2, u2, a3]), lookup(parts)) + + expect(rows.map((row) => `${row.turn}:${row.type}`)).toEqual([ + "u1:user", + "u1:assistant", + "u1:assistant", + "u1:assistant", + "u1:diff", + "u1:error", + "u2:user", + "u2:assistant", + ]) + expect(rows.filter((row) => row.type === "assistant").map((row) => row.parts.length)).toEqual([8, 2, 1, 1]) + }) + + it("uses the configured bound and keeps an empty assistant renderable", () => { + const u1 = user("u1") + const a1 = assistant("a1", "u1") + const a2 = assistant("a2", "u1") + const rows = transcriptRows( + messageTurns([u1, a1, a2]), + lookup({ a1: Array.from({ length: 7 }, (_, i) => part(`p${i}`, "a1")) }), + { size: 3 }, + ) + + expect(rows.filter((row) => row.type === "assistant").map((row) => row.parts.length)).toEqual([3, 3, 1, 0]) + }) + + it("omits synthetic users for partial turns and carries row metadata", () => { + const a1 = assistant("a1", "u1") + const rows = transcriptRows(messageTurns([a1]), lookup({ a1: [part("p1", "a1")] }), { + queued: new Set(["u1"]), + live: new Set(["u1"]), + }) + + expect(rows).toHaveLength(1) + expect(rows[0]).toMatchObject({ type: "assistant", turn: "u1", partial: true, queued: true, live: true }) + }) + + it("places only the first visible non-abort error after diffs", () => { + const u1 = user("u1", { summary: { diffs: [{ file: "a.ts" }] } }) + const a1 = assistant("a1", "u1", { error: { name: "MessageAbortedError" } }) + const a2 = assistant("a2", "u1", { error: { name: "HiddenError" } }) + const a3 = assistant("a3", "u1", { error: { name: "ShownError" } }) + const rows = transcriptRows(messageTurns([u1, a1, a2, a3]), lookup({}), { hidden: (id) => id === "a2" }) + + expect(rows.slice(-2).map((row) => row.type)).toEqual(["diff", "error"]) + expect(rows.at(-1)).toMatchObject({ type: "error", message: a3, error: a3.error }) + }) + + it("keeps keys stable when older turns are prepended and parts are appended", () => { + const u1 = user("u1") + const a1 = assistant("a1", "u1") + const parts = Array.from({ length: 8 }, (_, i) => part(`p${i}`, "a1")) + const current = transcriptRows(messageTurns([u1, a1]), lookup({ a1: parts })) + const older = user("u0") + const next = transcriptRows(messageTurns([older, u1, a1]), lookup({ a1: [...parts, part("p8", "a1")] })) + + expect(next.find((row) => row.type === "user" && row.turn === "u1")?.key).toBe(current[0]?.key) + expect(next.find((row) => row.type === "assistant" && row.parts[0]?.id === "p0")?.key).toBe(current[1]?.key) + }) + + it("reuses unchanged rows across prepend and append updates", () => { + const u1 = user("u1") + const a1 = assistant("a1", "u1") + const u2 = user("u2") + const p1 = part("p1", "a1") + const first = transcriptRows(messageTurns([u1, a1, u2]), lookup({ a1: [p1] })) + const u0 = user("u0") + const a2 = assistant("a2", "u2") + const second = transcriptRows(messageTurns([u0, u1, a1, u2, a2]), lookup({ a1: [p1] }), {}, first) + + expect(second[1]).toBe(first[0]) + expect(second[2]).toBe(first[1]) + expect(second[3]).not.toBe(first[2]) + expect(second[4]).not.toBe(first[2]) + }) + + it("selects the last real assistant text part as the copy target", () => { + const u1 = user("u1") + const a1 = assistant("a1", "u1") + const a2 = assistant("a2", "u1") + const synthetic: Part = { ...part("p2", "a2"), synthetic: true } + const blank: Part = { ...part("p3", "a2"), text: " " } + const rows = transcriptRows( + messageTurns([u1, a1, a2]), + lookup({ a1: [part("p1", "a1")], a2: [synthetic, blank] }), + ) + + expect(rows.filter((row) => row.type === "assistant").map((row) => row.copy)).toEqual(["p1", "p1"]) + }) + + it("keeps compaction replies ordered under the compacted turn and respects revert turns", () => { + const u1 = user("u1") + const a1 = assistant("a1", "u1") + const u2 = user("u2", { + parts: [{ id: "compact", messageID: "u2", type: "compaction", auto: false }], + }) + const a2 = assistant("a2", "u1") + const u3 = user("u3") + const turns = messageTurns([u1, a1, u2, a2, u3], "u3") + const rows = transcriptRows(turns, (id) => (id === "u2" ? u2.parts ?? [] : [])) + + expect(rows.map((row) => `${row.turn}:${row.message.id}`)).toEqual([ + "u1:u1", + "u1:a1", + "u2:u2", + "u2:a2", + ]) + }) + + it("replaces only rows whose data or metadata changed", () => { + const u1 = user("u1") + const a1 = assistant("a1", "u1") + const p1 = part("p1", "a1") + const first = transcriptRows(messageTurns([u1, a1]), lookup({ a1: [p1] })) + const changed = { ...p1, text: "changed" } + const second = transcriptRows(messageTurns([u1, a1]), lookup({ a1: [changed] }), {}, first) + + expect(second[0]).toBe(first[0]) + expect(second[1]).not.toBe(first[1]) + + const live = transcriptRows(messageTurns([u1, a1]), lookup({ a1: [changed] }), { live: new Set(["u1"]) }, second) + expect(live[0]).not.toBe(second[0]) + expect(live[1]).not.toBe(second[1]) + }) +}) + +describe("partitionRows", () => { + it("pins only a bounded live suffix and virtualizes completed history", () => { + const u1 = user("u1") + const a1 = assistant("a1", "u1") + const u2 = user("u2") + const a2 = assistant("a2", "u2") + const parts = Array.from({ length: 18 }, (_, i) => part(`p${i}`, "a2")) + const rows = transcriptRows(messageTurns([u1, a1, u2, a2]), lookup({ a1: [part("old", "a1")], a2: parts }), { + live: new Set(["u2"]), + }) + const result = partitionRows(rows) + + expect(result.keep).toEqual([result.virtual.length - 2, result.virtual.length - 1]) + expect(result.keep.map((idx) => result.virtual[idx]).every((row) => row?.live && row.turn === "u2")).toBe(true) + expect( + result.keep + .flatMap((idx) => { + const row = result.virtual[idx] + return row?.type === "assistant" ? row.parts : [] + }) + .map((item) => item.id), + ).toEqual(["p8", "p9", "p10", "p11", "p12", "p13", "p14", "p15", "p16", "p17"]) + expect(result.virtual.some((row) => row.turn === "u1")).toBe(true) + expect(result.virtual.some((row) => row.turn === "u2" && row.type === "user")).toBe(true) + }) + + it("returns completed live metadata to virtual history after queue handoff", () => { + const u1 = user("u1") + const a1 = assistant("a1", "u1") + const u2 = user("u2") + const first = transcriptRows(messageTurns([u1, a1, u2]), lookup({ a1: [part("p1", "a1")] }), { + live: new Set(["u1"]), + queued: new Set(["u2"]), + }) + const active = partitionRows(first) + expect(active.keep).toEqual([0, 1]) + expect(active.keep.map((idx) => active.virtual[idx]?.turn)).toEqual(["u1", "u1"]) + expect(active.queued.map((row) => row.turn)).toEqual(["u2"]) + + const second = transcriptRows(messageTurns([u1, a1, u2]), lookup({ a1: [part("p1", "a1")] }), { + live: new Set(["u2"]), + }) + const handed = partitionRows(second) + + expect(handed.keep.map((idx) => handed.virtual[idx]?.turn)).toEqual(["u2"]) + expect(handed.virtual.filter((row) => row.turn === "u1")).toHaveLength(2) + }) + + it("keeps queued rows in visual order inside virtual data", () => { + const u1 = user("u1") + const u2 = user("u2") + const rows = transcriptRows(messageTurns([u1, u2]), lookup({}), { + live: new Set(["u1"]), + queued: new Set(["u2"]), + }) + const result = partitionRows(rows) + + expect(result.virtual.map((row) => row.turn)).toEqual(["u1"]) + expect(result.keep).toEqual([0]) + expect(result.queued.map((row) => row.turn)).toEqual(["u2"]) + expect(result.queued[0]).toMatchObject({ type: "user", queued: true }) + }) +}) 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 92fb514d580..1b93f888255 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx @@ -110,6 +110,7 @@ function matchToolRequest = (props) => { const open = createMemo(() => config().terminal_command_display !== "collapsed") const parts = createMemo(() => { - const stored = data.store.part?.[props.message.id] + const stored = props.parts ?? data.store.part?.[props.message.id] if (!stored) return [] return (stored as SDKPart[]).filter((part) => isRenderable(part)) }) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx index eaf4243e970..107a4d7e64e 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx @@ -20,23 +20,32 @@ import { useLanguage } from "../../context/language" import { recentSessions } from "../../context/session-utils" import { formatRelativeDate } from "../../utils/date" import { FeedbackDialog } from "./FeedbackDialog" -import { VscodeSessionTurn } from "./VscodeSessionTurn" +import { TranscriptRowView } from "./TranscriptRow" import { RevertBanner } from "./RevertBanner" import { AccountSwitcher } from "../shared/AccountSwitcher" import { KiloNotifications } from "./KiloNotifications" import { WorkingIndicator } from "../shared/WorkingIndicator" import { TurnOutcome } from "../shared/TurnOutcome" import { QuestionDock } from "./QuestionDock" -import { Virtualizer } from "virtua/solid" +import { Virtualizer, type VirtualizerHandle } from "virtua/solid" import { SuggestBar } from "./SuggestBar" +import { + getMeasurement, + getScroll, + layoutFingerprint, + resolveAnchor, + rowFingerprint, + setMeasurement, + setScroll, +} from "./transcript-cache" import { activeUserMessageID as getActiveUserMessageID, messageTurns, - partitionTurns, queuedUserMessageIDs, stableMessageTurns, type MessageTurn, } from "../../context/session-queue" +import { partitionRows, transcriptRows, type TranscriptRow } from "../../context/transcript-rows" import type { QuestionRequest, SuggestionRequest } from "../../types/messages" const KiloLogo = (): JSX.Element => { @@ -88,7 +97,8 @@ export const MessageList: Component = (props) => { }) const [scrollEl, setScrollEl] = createSignal() - const positions = new Map() + const [virtualizer, setVirtualizer] = createSignal() + const [layout, setLayout] = createSignal("") const boundary = () => session.revert()?.messageID const turns = createMemo((prev: MessageTurn[] | undefined) => @@ -107,45 +117,55 @@ export const MessageList: Component = (props) => { const queuedIDs = createMemo( () => new Set(queuedUserMessageIDs(session.messages(), session.statusInfo(), (msg) => session.getParts(msg.id))), ) - const [held, setHeld] = createSignal<{ sid: string; ids: Set }>() - createEffect(() => { - const id = activeUserID() - const sid = session.currentSessionID() - const paused = autoScroll.userScrolled() - if (!sid || (!id && !paused)) { - setHeld(undefined) - return - } - if (!id) return - if (!paused) { - setHeld({ sid, ids: new Set([id]) }) - return - } - setHeld((prev) => { - if (prev?.sid === sid && prev.ids.has(id)) return prev - const ids = prev?.sid === sid ? new Set(prev.ids) : new Set() - ids.add(id) - return { sid, ids } - }) - }) - const directIDs = createMemo(() => { - const item = held() - const ids = item && item.sid === session.currentSessionID() ? new Set(item.ids) : new Set() + const rows = createMemo((prev: TranscriptRow[] | undefined) => { const active = activeUserID() - if (active) ids.add(active) - return ids + return transcriptRows( + turns(), + (msg) => session.getParts(msg), + { + queued: queuedIDs(), + live: new Set(active ? [active] : []), + hidden: session.isErrorHidden, + }, + prev, + ) + }) + const partition = createMemo(() => partitionRows(rows())) + const keys = createMemo(() => partition().virtual.map((row) => row.key)) + const fingerprint = createMemo(() => rowFingerprint(keys())) + const measurement = createMemo(() => { + const id = session.currentSessionID() + const token = layout() + if (!id || !token || session.loading() || keys().length === 0) return undefined + return getMeasurement(id, fingerprint(), token) }) - // Keep the growing live turn out of Virtua. Resizing a tall virtual item while - // the user reads within it makes Virtua compensate scrollTop as if earlier - // content moved, dragging the viewport downward during streaming. Preserve - // direct-rendered tail turns while paused so completion and queue handoffs do - // not move a turn being read back into the virtualized history. - const partition = createMemo(() => partitionTurns(turns(), directIDs(), queuedIDs())) - const save = (id: string | undefined) => { + let active = { id: session.currentSessionID(), keys: keys(), fingerprint: fingerprint() } + createEffect(() => { + const id = session.currentSessionID() + const current = keys() + const value = fingerprint() + if (!id || session.loading() || active.id !== id) return + active = { id, keys: current, fingerprint: value } + }) + + const save = (id: string | undefined, saved = active) => { const el = scrollEl() - if (!id || !el) return - positions.set(id, { top: el.scrollTop, userScrolled: autoScroll.userScrolled() }) + if (!id || !el || saved.id !== id) return + const handle = virtualizer() + const token = layout() + if (handle && token && saved.keys.length > 0) { + setMeasurement(id, saved.fingerprint, token, handle.cache) + } + if (!autoScroll.userScrolled()) { + setScroll(id, { type: "bottom" }) + return + } + if (!handle || saved.keys.length === 0) return + const index = handle.findStartIndex() + const key = saved.keys[index] + if (!key) return + setScroll(id, { type: "anchor", key, offset: handle.scrollOffset - handle.getItemOffset(index) }) } const maybeLoadOlder = () => { @@ -159,16 +179,44 @@ export const MessageList: Component = (props) => { maybeLoadOlder() } + let resize: ResizeObserver | undefined + const refreshLayout = () => { + const el = scrollEl() + if (!el) return + const style = getComputedStyle(el) + setLayout( + layoutFingerprint({ + width: Math.round(el.clientWidth), + ratio: window.devicePixelRatio, + font: style.fontFamily, + size: style.fontSize, + line: style.lineHeight, + }), + ) + } const setScrollRef = (el: HTMLElement | undefined) => { + resize?.disconnect() setScrollEl(el) autoScroll.scrollRef(el) + if (!el) return + refreshLayout() + resize = new ResizeObserver(refreshLayout) + resize.observe(el) } + window.addEventListener("resize", refreshLayout) + document.fonts?.addEventListener("loadingdone", refreshLayout) + onCleanup(() => { + resize?.disconnect() + window.removeEventListener("resize", refreshLayout) + document.fonts?.removeEventListener("loadingdone", refreshLayout) + }) const [pendingRestore, setPendingRestore] = createSignal() createEffect( on(session.currentSessionID, (id, prev) => { save(prev) + active = { id, keys: [], fingerprint: rowFingerprint([]) } setPendingRestore(id) }), ) @@ -185,9 +233,11 @@ export const MessageList: Component = (props) => { if (pendingRestore() !== id) return const el = scrollEl() if (!el) return - const pos = positions.get(id) - if (pos?.userScrolled) { - el.scrollTop = pos.top + const state = getScroll(id) + const anchor = resolveAnchor(state, keys()) + const handle = virtualizer() + if (state?.type === "anchor" && anchor && handle) { + handle.scrollToIndex(anchor.index, { offset: anchor.offset }) autoScroll.pause() maybeLoadOlder() } else { @@ -262,28 +312,36 @@ export const MessageList: Component = (props) => { {language.t("session.messages.loadEarlier")} - 0 || partition().direct.length > 0}> -
- 0}> + 0}> +
+ - {(turn) => } + {(row, index) => ( + + )} - - {(turn) => } -
- {(turn) => } + {(row) => } {(req) => } 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 3f4efb1db5b..17f5f36a7aa 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/TaskTimeline.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/TaskTimeline.tsx @@ -1,21 +1,13 @@ /** - * TaskTimeline — horizontal strip of colored bars representing session activity. - * - * Each bar = one Part from assistant messages. - * Color = part type (read=blue, write=dark blue, tool=indigo, error=red, text=gray). - * Width = proportional to time between parts. - * Height = proportional to content length. - * - * Interactions: drag scroll, mouse wheel, auto-scroll to latest. - * - * No virtualization needed: SolidJS creates each element once and - * updates bindings in place (unlike React). Even 1000+ bars are fine. + * Horizontal session activity timeline rendered as color-grouped SVG paths. + * Pointer and keyboard interaction use the same pure bar geometry. */ -import { Component, Index, Show, createMemo, createEffect, createSignal, on, onCleanup } from "solid-js" +import { Component, For, Show, createMemo, createEffect, createSignal, on, onCleanup } from "solid-js" import { Portal } from "solid-js/web" import { useSession } from "../../context/session" import { color, label } from "../../utils/timeline/colors" +import { geometry, hit, navigate } from "../../utils/timeline/geometry" import { sizes, pinned, MAX_HEIGHT } from "../../utils/timeline/sizes" import type { Part, Message } from "../../types/messages" @@ -56,7 +48,8 @@ export const TaskTimeline: Component = () => { let dragging = false let startX = 0 let startScroll = 0 - let tipBar: HTMLElement | undefined + const [hover, setHover] = createSignal(-1) + const [active, setActive] = createSignal(-1) const [tip, setTip] = createSignal<{ text: string; x: number; y: number }>() const messages = () => session.visibleMessages() @@ -71,12 +64,20 @@ export const TaskTimeline: Component = () => { } const bars = createMemo(() => collect(messages(), allParts())) + const layout = createMemo(() => geometry(bars(), MAX_HEIGHT)) const busy = () => session.status() === "busy" + const selected = () => { + const idx = active() + if (idx >= 0 && idx < bars().length) return idx + return bars().length - 1 + } + 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}` + } - // Reading scrollWidth and writing scrollLeft synchronously for every appended bar can - // force repeated layout during streamed part updates. Batch those appends behind one - // animation frame, after Solid has applied the current DOM updates. Only follow while - // pinned so inspecting earlier activity is not interrupted by incoming bars. let prev = 0 let frame: number | undefined let follow = true @@ -87,6 +88,7 @@ export const TaskTimeline: Component = () => { on( () => bars().length, (len) => { + if (active() >= len) setActive(len - 1) if (len > prev && ref && follow && frame === undefined) { frame = requestAnimationFrame(() => { frame = undefined @@ -103,28 +105,32 @@ export const TaskTimeline: Component = () => { }) const hideTip = () => { - tipBar = undefined + setHover(-1) setTip(undefined) } createEffect(on(bars, hideTip, { defer: true })) - const showTip = (e: PointerEvent) => { - if (dragging || !(e.target instanceof Element)) return - const bar = e.target.closest(".task-timeline-bar") - if (!bar || !ref?.contains(bar)) return hideTip() - if (bar === tipBar) return - const rect = bar.getBoundingClientRect() - tipBar = bar + const showTip = (idx: number) => { + const item = layout().items[idx] + const bar = bars()[idx] + if (!ref || !item || !bar) return hideTip() + const rect = ref.getBoundingClientRect() const margin = Math.min(160, window.innerWidth / 2) + setHover(idx) setTip({ - text: bar.dataset.tip ?? "", - x: Math.max(margin, Math.min(window.innerWidth - margin, rect.left + rect.width / 2)), - y: rect.top, + text: bar.tip, + x: Math.max(margin, Math.min(window.innerWidth - margin, rect.left + item.x - ref.scrollLeft + item.width / 2)), + y: rect.top + MAX_HEIGHT - item.height, }) } - // ── Drag scroll ────────────────────────────────────────────────── + const pointerIndex = (e: PointerEvent) => { + if (!ref) return -1 + const rect = ref.getBoundingClientRect() + return hit(layout().items, e.clientX - rect.left + ref.scrollLeft) + } + const onPointerDown = (e: PointerEvent) => { hideTip() if (!ref) return @@ -137,19 +143,24 @@ export const TaskTimeline: Component = () => { } const onPointerMove = (e: PointerEvent) => { - if (!dragging || !ref) return showTip(e) + if (!ref) return + if (!dragging) { + const idx = pointerIndex(e) + if (idx === hover()) return + if (idx < 0) return hideTip() + return showTip(idx) + } ref.scrollLeft = startScroll - (e.clientX - startX) } const onPointerUp = (e: PointerEvent) => { if (!ref) return dragging = false - ref.releasePointerCapture(e.pointerId) + if (ref.hasPointerCapture(e.pointerId)) ref.releasePointerCapture(e.pointerId) ref.style.cursor = "grab" ref.style.userSelect = "" } - // ── Wheel → horizontal scroll ──────────────────────────────────── const onWheel = (e: WheelEvent) => { hideTip() if (!ref) return @@ -157,6 +168,20 @@ export const TaskTimeline: Component = () => { ref.scrollLeft += e.deltaY || e.deltaX } + const onKeyDown = (e: KeyboardEvent) => { + if (!ref || !["ArrowLeft", "ArrowRight", "Home", "End"].includes(e.key)) return + e.preventDefault() + const idx = navigate(selected(), bars().length, e.key) + setActive(idx) + const item = layout().items[idx] + if (!item) return + const left = item.x + const right = item.x + item.width + if (left < ref.scrollLeft) ref.scrollLeft = left + if (right > ref.scrollLeft + ref.clientWidth) ref.scrollLeft = right - ref.clientWidth + showTip(idx) + } + createEffect(() => { const el = ref if (!el) return @@ -164,13 +189,37 @@ export const TaskTimeline: Component = () => { onCleanup(() => el.removeEventListener("wheel", onWheel)) }) + const overlay = (idx: number, pulse = false) => { + const item = layout().items[idx] + if (!item) return null + return ( +