From f894108d4522550f2ac13e14ebb4f7dc3d5c531c Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 15 Sep 2026 11:14:49 +0200 Subject: [PATCH 1/5] feat(vscode): show chat message timestamps on hover Reveal a message's time inline in its existing action row on hover: the prompt time on user messages, and the finish time plus turn duration on assistant messages. Queued user messages keep their controls in the same reserved row so unqueueing no longer shifts the transcript. Timestamps use Intl with the Kilo UI language, and the shared I18n locale is now a BCP-47 tag so languages like Traditional Chinese stop falling back to en-US. --- .changeset/chat-message-timestamps.md | 5 ++ .../kilo-ui/src/components/message-part.css | 34 ++++++++- .../kilo-ui/src/components/message-part.tsx | 71 +++++++++++-------- .../tests/unit/message-time.test.ts | 40 +++++++++++ .../tests/unit/transcript-rows.test.ts | 18 +++++ .../src/components/chat/AssistantMessage.tsx | 24 +++++++ .../src/components/chat/TranscriptRow.tsx | 1 + .../webview-ui/src/context/language.tsx | 4 +- .../webview-ui/src/context/transcript-rows.ts | 45 ++++++++++-- .../webview-ui/src/styles/chat-layout.css | 7 +- .../webview-ui/src/utils/message-time.ts | 21 ++++++ 11 files changed, 231 insertions(+), 39 deletions(-) create mode 100644 .changeset/chat-message-timestamps.md create mode 100644 packages/kilo-vscode/tests/unit/message-time.test.ts create mode 100644 packages/kilo-vscode/webview-ui/src/utils/message-time.ts diff --git a/.changeset/chat-message-timestamps.md b/.changeset/chat-message-timestamps.md new file mode 100644 index 00000000000..abadf56178d --- /dev/null +++ b/.changeset/chat-message-timestamps.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Show message timestamps on hover in the chat transcript, including the turn's finish time and duration. \ No newline at end of file diff --git a/packages/kilo-ui/src/components/message-part.css b/packages/kilo-ui/src/components/message-part.css index c1c6670204d..c00c6ab7da8 100644 --- a/packages/kilo-ui/src/components/message-part.css +++ b/packages/kilo-ui/src/components/message-part.css @@ -15,12 +15,17 @@ display: flex; } + /* The whole assistant action row (copy, feedback, tokens/s, time) follows + the same rule as the user row: hidden at rest, revealed on hover or + focus. Opacity keeps the row height so the transcript never shifts. */ [data-slot="assistant-copy-wrapper"] { display: flex; align-items: center; justify-content: flex-start; gap: 2px; margin-top: 2px; + opacity: 0; + transition: opacity 0.15s ease; /* Throughput badge sits to the right of the copy/feedback buttons, beside them rather than beneath the message. */ @@ -29,6 +34,22 @@ display: flex; align-items: center; } + + /* Turn finish time and duration sit beside the actions, in the same + text style as the tokens/s value. */ + [data-slot="assistant-turn-meta"] { + margin-left: 6px; + display: flex; + align-items: center; + font-variant-numeric: tabular-nums; + white-space: nowrap; + user-select: none; + } + } + + &:hover [data-slot="assistant-copy-wrapper"], + &:focus-within [data-slot="assistant-copy-wrapper"] { + opacity: 1; } } @@ -362,12 +383,16 @@ html[data-theme="kilo-vscode"] [data-component="bash-output"] { display: inline-flex; align-items: center; gap: 6px; - margin-top: 6px; - margin-right: 2px; font-size: var(--font-size-small); color: var(--text-weak); user-select: none; } + + /* While queued, the action row holds the queued controls and must stay + visible without hover. */ + [data-slot="user-message-copy-wrapper"][data-queued] { + opacity: 1; + } } body.vscode-high-contrast [data-component="user-message"] [data-slot="user-message-text"], @@ -458,7 +483,10 @@ html[data-theme="kilo-vscode"] [data-component="todos"] { height: 20px; margin-top: 0; - [data-slot="user-message-meta-wrap"] { + /* The hover action row keeps only the send time from the message meta: + the agent/model head is hidden so the row stays compact. */ + [data-slot="user-message-meta"], + [data-slot="user-message-meta-sep"] { display: none; } } diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx index 4b7a43af0a3..9a797275149 100644 --- a/packages/kilo-ui/src/components/message-part.tsx +++ b/packages/kilo-ui/src/components/message-part.tsx @@ -176,6 +176,9 @@ export interface MessagePartProps { working?: boolean feedback?: MessageFeedbackControls throughput?: JSX.Element + /** Finish time and duration for the turn, rendered inline in the assistant + * copy/feedback action row rather than on its own line. */ + turnMeta?: JSX.Element readonly?: boolean } @@ -815,11 +818,7 @@ export function UserMessageDisplay(props: { const stamp = createMemo(() => { const created = props.message.time?.created if (typeof created !== "number") return "" - const date = new Date(created) - const hours = date.getHours() - const hour12 = hours % 12 || 12 - const minute = String(date.getMinutes()).padStart(2, "0") - return `${hour12}:${minute} ${hours < 12 ? "AM" : "PM"}` + return new Intl.DateTimeFormat(i18n.locale(), { timeStyle: "short" }).format(new Date(created)) }) const metaHead = createMemo(() => { @@ -941,17 +940,23 @@ export function UserMessageDisplay(props: { - + + + {/* Queued controls live in the same reserved action row as the + hover actions, so unqueueing swaps content without a height change. */} +
+
- -
- -
- + + @@ -1001,23 +1006,25 @@ export function UserMessageDisplay(props: { /> - - e.preventDefault()} - onClick={(event) => { - event.stopPropagation() - handleCopy() - }} - aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")} - /> - + + + e.preventDefault()} + onClick={(event) => { + event.stopPropagation() + handleCopy() + }} + aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")} + /> + +
@@ -1093,6 +1100,7 @@ export function Part(props: MessagePartProps) { working={props.working} feedback={props.feedback} throughput={props.throughput} + turnMeta={props.turnMeta} readonly={props.readonly} /> @@ -1826,6 +1834,13 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { {(el) => {el()}} + + {(el) => ( + + {el()} + + )} + diff --git a/packages/kilo-vscode/tests/unit/message-time.test.ts b/packages/kilo-vscode/tests/unit/message-time.test.ts new file mode 100644 index 00000000000..0bb022e4167 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/message-time.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from "bun:test" +import { formatClock, formatDuration } from "../../webview-ui/src/utils/message-time" +import { LOCALES, localeToBcp47 } from "../../webview-ui/src/context/language-utils" + +describe("message-time", () => { + it("resolves every Kilo UI language to a real Intl locale", () => { + for (const locale of LOCALES) { + const tag = localeToBcp47(locale) + const resolved = new Intl.DateTimeFormat(tag, { timeStyle: "short" }).resolvedOptions().locale + expect(resolved.split("-")[0]).toBe(tag.split("-")[0]) + } + }) + + it("formats the clock in the UI language's convention and local timezone", () => { + const at = Date.UTC(2026, 0, 1, 13, 5) + expect(formatClock(at, "en")).toBe(new Intl.DateTimeFormat("en", { timeStyle: "short" }).format(new Date(at))) + expect(formatClock(at, "de")).toBe(new Intl.DateTimeFormat("de", { timeStyle: "short" }).format(new Date(at))) + expect(formatClock(at, "de")).not.toMatch(/AM|PM/) + }) + + it("formats sub-minute durations as seconds", () => { + expect(formatDuration(0)).toBe("0s") + expect(formatDuration(1500)).toBe("2s") + expect(formatDuration(45_000)).toBe("45s") + }) + + it("formats minute-scale durations with seconds", () => { + expect(formatDuration(60_000)).toBe("1m 0s") + expect(formatDuration(125_000)).toBe("2m 5s") + }) + + it("formats hour-scale durations with minutes", () => { + expect(formatDuration(3_600_000)).toBe("1h 0m") + expect(formatDuration(3_780_000)).toBe("1h 3m") + }) + + it("clamps negative durations to zero", () => { + expect(formatDuration(-5000)).toBe("0s") + }) +}) diff --git a/packages/kilo-vscode/tests/unit/transcript-rows.test.ts b/packages/kilo-vscode/tests/unit/transcript-rows.test.ts index e6dcb724734..b8be8aca9b2 100644 --- a/packages/kilo-vscode/tests/unit/transcript-rows.test.ts +++ b/packages/kilo-vscode/tests/unit/transcript-rows.test.ts @@ -270,6 +270,24 @@ describe("transcriptRows", () => { expect(live[0]).not.toBe(second[0]) expect(live[1]).not.toBe(second[1]) }) + + it("attaches finish time and duration to the last assistant row of a settled turn", () => { + const u1 = user("u1", { time: { created: 1_000 } }) + const a1 = assistant("a1", "u1", { time: { created: 1_500, completed: 3_500 } }) + const rows = transcriptRows(messageTurns([u1, a1]), lookup({ a1: [part("p1", "a1")] })) + + expect(rows.find((row) => row.type === "assistant")).toMatchObject({ + timing: { completedAt: 3_500, durationMs: 2_500 }, + }) + }) + + it("omits turn timing while the last assistant message is still running", () => { + const u1 = user("u1", { time: { created: 1_000 } }) + const a1 = assistant("a1", "u1", { time: { created: 1_500 } }) + const rows = transcriptRows(messageTurns([u1, a1]), lookup({ a1: [part("p1", "a1")] })) + + expect(rows.find((row) => row.type === "assistant")?.timing).toBeUndefined() + }) }) describe("retainTurn", () => { 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 e677be1a5ac..81b3dd52957 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx @@ -33,6 +33,8 @@ import { useServer } from "../../context/server" import { planDisplayPath } from "../../utils/plan-path" import { isRenderable, UPSTREAM_SUPPRESSED_TOOLS } from "../../utils/transcript-parts" import { messageThroughput, formatTG } from "../../context/session-utils" +import { formatClock, formatDuration } from "../../utils/message-time" +import type { TurnTiming } from "../../context/transcript-rows" import { color as timelineColor } from "../../utils/timeline/colors" import type { Part as TimelinePart } from "../../types/messages" import type { TimelineHighlight } from "../../utils/timeline/highlight" @@ -105,6 +107,9 @@ interface AssistantMessageProps { message: SDKAssistantMessage parts?: SDKPart[] showAssistantCopyPartID?: string | null + /** Finish time and duration for the turn, shown inline in the assistant + * action row once the turn settles. */ + timing?: TurnTiming feedback?: MessageFeedbackControls /** id of the part containing the current chat-search match, if any โ€” forces * that part's collapsed tool/reasoning content open so the user can see @@ -322,6 +327,24 @@ export const AssistantMessage: Component = (props) => { return }) + // Turn finish time and duration render inline in the same action row + // as the copy/feedback buttons, on the trailing side, so the turn's + // timing never introduces a second line. Only the copy-carrying part + // builds it, which keeps it to one row per settled turn. + const turnMetaEl = createMemo(() => { + const timing = props.timing + if (!timing) return undefined + if (part.id !== props.showAssistantCopyPartID) return undefined + return ( + + {formatClock(timing.completedAt, language.locale())} + + {(ms) => ยท {formatDuration(ms())}} + + + ) + }) + return ( = (props) => { settled={settled()} feedback={props.feedback} throughput={throughputEl()} + turnMeta={turnMetaEl()} readonly={props.readonly} animate={ part.type === "tool" && diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/TranscriptRow.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/TranscriptRow.tsx index 222a517de28..d7b11975ec4 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/TranscriptRow.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/TranscriptRow.tsx @@ -110,6 +110,7 @@ export const TranscriptRowView: Component = (props) => { message={row().message as unknown as SDKAssistantMessage} parts={row().parts as unknown as SDKPart[]} showAssistantCopyPartID={row().copy} + timing={row().timing} forceOpenPartID={props.activeSearchPartID} forceOpenFile={props.activeSearchPartFile} highlight={props.highlight} diff --git a/packages/kilo-vscode/webview-ui/src/context/language.tsx b/packages/kilo-vscode/webview-ui/src/context/language.tsx index c94f6f05254..beef1dfa823 100644 --- a/packages/kilo-vscode/webview-ui/src/context/language.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/language.tsx @@ -217,7 +217,9 @@ export const LanguageProvider: ParentComponent = (props) string }} > - locale(), t, plural }}>{props.children} + {/* Shared UI formats dates and numbers with Intl from this value, so it + must be a BCP-47 tag (Kilo's "zht" is not one). */} + localeToBcp47(locale()), t, plural }}>{props.children} ) } diff --git a/packages/kilo-vscode/webview-ui/src/context/transcript-rows.ts b/packages/kilo-vscode/webview-ui/src/context/transcript-rows.ts index 469c50909ab..14010c2e2a6 100644 --- a/packages/kilo-vscode/webview-ui/src/context/transcript-rows.ts +++ b/packages/kilo-vscode/webview-ui/src/context/transcript-rows.ts @@ -17,12 +17,22 @@ export interface TranscriptUserRow extends TranscriptMeta { answered: boolean } +/** + * Wall-clock finish time and duration for a completed turn, shown as the + * turn's chat-line timestamp. + */ +export interface TurnTiming { + completedAt: number + durationMs?: number +} + export interface TranscriptAssistantRow extends TranscriptMeta { type: "assistant" key: string message: Message parts: Part[] copy?: string + timing?: TurnTiming } export interface TranscriptDiffRow extends TranscriptMeta { @@ -84,6 +94,10 @@ function meta(a: TranscriptRow, b: TranscriptRow) { return a.turn === b.turn && a.partial === b.partial && a.queued === b.queued && a.live === b.live } +function sameTiming(a?: TurnTiming, b?: TurnTiming) { + return a?.completedAt === b?.completedAt && a?.durationMs === b?.durationMs +} + function equal(a: TranscriptRow, b: TranscriptRow) { if (a.type !== b.type || !meta(a, b)) return false if (a.type === "user" && b.type === "user") { @@ -92,7 +106,7 @@ function equal(a: TranscriptRow, b: TranscriptRow) { ) } if (a.type === "assistant" && b.type === "assistant") { - return a.message === b.message && same(a.parts, b.parts) && a.copy === b.copy + return a.message === b.message && same(a.parts, b.parts) && a.copy === b.copy && sameTiming(a.timing, b.timing) } if (a.type === "diff" && b.type === "diff") { return a.message === b.message && same(a.diffs, b.diffs) @@ -108,6 +122,21 @@ function diffs(msg: Message) { return msg.summary.diffs ?? [] } +/** + * Finish time and duration for a settled turn, derived the same way as the + * TUI session view: the last assistant message's completion time minus the + * user prompt's creation time. Partial (not-yet-parented) turns are skipped + * because their synthetic user message reuses an assistant timestamp. + */ +function turnTiming(turn: MessageTurn) { + if (turn.partial) return undefined + const end = turn.assistant.at(-1)?.time?.completed + if (typeof end !== "number") return undefined + const start = turn.user.time?.created + if (typeof start !== "number") return { completedAt: end } + return { completedAt: end, durationMs: Math.max(0, end - start) } +} + function content(parts: Part[]) { const text = parts.find((part) => part.type === "text" && !part.synthetic) if (text?.type === "text" && text.text.trim()) return true @@ -165,32 +194,38 @@ export function transcriptRows( }) } + let tail: TranscriptAssistantRow | undefined for (const msg of turn.assistant) { const visible = parts(msg.id) if (visible.length === 0) { - rows.push({ + tail = { ...meta, type: "assistant", key: `${turn.id}:assistant:${msg.id}:empty`, message: msg, parts: visible, copy: copied, - }) + } + rows.push(tail) continue } for (let start = 0; start < visible.length; start += size) { const chunk = visible.slice(start, start + size) - rows.push({ + tail = { ...meta, type: "assistant", key: `${turn.id}:assistant:${msg.id}:${chunk[0]!.id}`, message: msg, parts: chunk, copy: copied, - }) + } + rows.push(tail) } } + const timing = turnTiming(turn) + if (tail && timing) tail.timing = timing + const changes = diffs(turn.user) if (changes.length > 0) { rows.push({ ...meta, type: "diff", key: `${turn.id}:diff`, message: turn.user, diffs: changes }) 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 b7e638e35c6..9118faf510a 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/chat-layout.css +++ b/packages/kilo-vscode/webview-ui/src/styles/chat-layout.css @@ -466,11 +466,14 @@ width: 100%; } -[data-component="assistant-throughput"] { +[data-component="assistant-throughput"], +[data-slot="assistant-turn-meta"], +[data-component="user-message"] [data-slot="user-message-meta-tail"] { /* Plain-text generation-speed value shown beside the copy/feedback buttons * on an assistant message. Renders as muted metadata โ€” no icon, no * background, no border โ€” so it reads as tertiary info that never - * competes with the action row for visual weight. */ + * competes with the action row for visual weight. The hover timestamps on + * both message types share this exact style so the action rows match. */ color: var(--vscode-descriptionForeground); font-family: var(--font-family-sans); font-size: var(--kilo-font-size-11); diff --git a/packages/kilo-vscode/webview-ui/src/utils/message-time.ts b/packages/kilo-vscode/webview-ui/src/utils/message-time.ts new file mode 100644 index 00000000000..cd1b9dda499 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/utils/message-time.ts @@ -0,0 +1,21 @@ +import { localeToBcp47, type Locale } from "../context/language-utils" + +/** + * Wall-clock time for chat-line metadata in the user's timezone and the UI + * language's conventions (12h or 24h). Same Intl call as the user-message + * stamp in kilo-ui and the TUI session view. + */ +export function formatClock(ms: number, locale: Locale): string { + return new Intl.DateTimeFormat(localeToBcp47(locale), { timeStyle: "short" }).format(new Date(ms)) +} + +/** Compact duration for chat-line metadata, for example "45s", "2m 5s", "1h 3m". */ +export function formatDuration(ms: number): string { + const total = Math.max(0, Math.round(ms / 1000)) + const seconds = total % 60 + const minutes = Math.floor(total / 60) % 60 + const hours = Math.floor(total / 3600) + if (hours > 0) return `${hours}h ${minutes}m` + if (minutes > 0) return `${minutes}m ${seconds}s` + return `${seconds}s` +} From 90561b1508ac20dc8f82b15f978c84459b258dfa Mon Sep 17 00:00:00 2001 From: "kilo-maintainer[bot]" Date: Tue, 15 Sep 2026 09:34:47 +0000 Subject: [PATCH 2/5] chore: update kilo-vscode visual regression baselines --- .../chat/prompt-rail-sidebar-chromium-linux.png | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-sidebar-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-sidebar-chromium-linux.png index bad0ffca4b9..6791f15a871 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-sidebar-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-sidebar-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:571af8de7c0ce6f7f960269032f11f2084549935b8a4b5f077f89bf9fb2206b9 -size 30146 +oid sha256:9a1ef550395ac38a42daac6dacaab52a11279720a783e47857c653ac1268c66e +size 29993 From 66259445e6efeb0bb363ecf3cac69f0e7061700a Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 15 Sep 2026 11:36:50 +0200 Subject: [PATCH 3/5] fix(vscode): address chat timestamp review feedback Attach turn timing to the row that owns the copy part, because that is where it renders. The copy part can sit in an earlier chunk, and a tool-only or empty trailing message has no action row, so anchoring on the turn's last row could drop the timing silently. Mirror the TUI finish guard so a turn that ends on a tool call does not show a finish time, and keep the queued action row clickable without hover by restoring pointer-events. The clock test now asserts the observable 12h and 24h convention instead of re-deriving formatClock's expression. --- .../kilo-ui/src/components/message-part.css | 3 +- .../tests/unit/message-time.test.ts | 8 ++-- .../tests/unit/transcript-rows.test.ts | 46 ++++++++++++++++++- .../webview-ui/src/context/transcript-rows.ts | 37 ++++++++++----- 4 files changed, 77 insertions(+), 17 deletions(-) diff --git a/packages/kilo-ui/src/components/message-part.css b/packages/kilo-ui/src/components/message-part.css index c00c6ab7da8..b125841e759 100644 --- a/packages/kilo-ui/src/components/message-part.css +++ b/packages/kilo-ui/src/components/message-part.css @@ -389,9 +389,10 @@ html[data-theme="kilo-vscode"] [data-component="bash-output"] { } /* While queued, the action row holds the queued controls and must stay - visible without hover. */ + visible and interactive without hover. */ [data-slot="user-message-copy-wrapper"][data-queued] { opacity: 1; + pointer-events: auto; } } diff --git a/packages/kilo-vscode/tests/unit/message-time.test.ts b/packages/kilo-vscode/tests/unit/message-time.test.ts index 0bb022e4167..a398c038f62 100644 --- a/packages/kilo-vscode/tests/unit/message-time.test.ts +++ b/packages/kilo-vscode/tests/unit/message-time.test.ts @@ -11,10 +11,12 @@ describe("message-time", () => { } }) - it("formats the clock in the UI language's convention and local timezone", () => { + it("formats the clock in the UI language's convention", () => { const at = Date.UTC(2026, 0, 1, 13, 5) - expect(formatClock(at, "en")).toBe(new Intl.DateTimeFormat("en", { timeStyle: "short" }).format(new Date(at))) - expect(formatClock(at, "de")).toBe(new Intl.DateTimeFormat("de", { timeStyle: "short" }).format(new Date(at))) + // 12-hour convention for English, 24-hour for German, regardless of the + // local timezone the test runs in. + expect(formatClock(at, "en")).toMatch(/^\d{1,2}:\d{2}\s?[AP]M$/) + expect(formatClock(at, "de")).toMatch(/^\d{1,2}:\d{2}$/) expect(formatClock(at, "de")).not.toMatch(/AM|PM/) }) diff --git a/packages/kilo-vscode/tests/unit/transcript-rows.test.ts b/packages/kilo-vscode/tests/unit/transcript-rows.test.ts index b8be8aca9b2..71b2db5388d 100644 --- a/packages/kilo-vscode/tests/unit/transcript-rows.test.ts +++ b/packages/kilo-vscode/tests/unit/transcript-rows.test.ts @@ -18,6 +18,14 @@ const assistant = (id: string, parentID: string, opts: Partial = {}): M ...opts, }) const part = (id: string, messageID: string): Part => ({ id, messageID, type: "text", text: id }) +const tool = (id: string, messageID: string): Part => ({ + id, + messageID, + type: "tool", + callID: id, + tool: "bash", + state: { status: "completed", input: {}, output: "" }, +}) const lookup = (values: Record) => (id: string) => values[id] ?? [] describe("transcriptRows", () => { @@ -271,9 +279,9 @@ describe("transcriptRows", () => { expect(live[1]).not.toBe(second[1]) }) - it("attaches finish time and duration to the last assistant row of a settled turn", () => { + it("attaches finish time and duration to the row that carries the copy part", () => { const u1 = user("u1", { time: { created: 1_000 } }) - const a1 = assistant("a1", "u1", { time: { created: 1_500, completed: 3_500 } }) + const a1 = assistant("a1", "u1", { finish: "stop", time: { created: 1_500, completed: 3_500 } }) const rows = transcriptRows(messageTurns([u1, a1]), lookup({ a1: [part("p1", "a1")] })) expect(rows.find((row) => row.type === "assistant")).toMatchObject({ @@ -281,6 +289,32 @@ describe("transcriptRows", () => { }) }) + it("keeps timing on the copy row when non-text parts follow in a later chunk", () => { + const u1 = user("u1", { time: { created: 1_000 } }) + const a1 = assistant("a1", "u1", { finish: "stop", time: { created: 1_500, completed: 3_500 } }) + const rows = transcriptRows( + messageTurns([u1, a1]), + lookup({ a1: [part("p0", "a1"), part("p1", "a1"), tool("t2", "a1"), tool("t3", "a1")] }), + { size: 2 }, + ) + const assistants = rows.filter((row) => row.type === "assistant") + + expect(assistants).toHaveLength(2) + expect(assistants[0]?.timing).toEqual({ completedAt: 3_500, durationMs: 2_500 }) + expect(assistants[1]?.timing).toBeUndefined() + }) + + it("keeps timing on the copy row when a later assistant message has no visible parts", () => { + const u1 = user("u1", { time: { created: 1_000 } }) + const a1 = assistant("a1", "u1") + const a2 = assistant("a2", "u1", { finish: "stop", time: { created: 1_600, completed: 3_600 } }) + const rows = transcriptRows(messageTurns([u1, a1, a2]), lookup({ a1: [part("p1", "a1")] })) + const assistants = rows.filter((row) => row.type === "assistant") + + expect(assistants[0]?.timing).toEqual({ completedAt: 3_600, durationMs: 2_600 }) + expect(assistants[1]?.timing).toBeUndefined() + }) + it("omits turn timing while the last assistant message is still running", () => { const u1 = user("u1", { time: { created: 1_000 } }) const a1 = assistant("a1", "u1", { time: { created: 1_500 } }) @@ -288,6 +322,14 @@ describe("transcriptRows", () => { expect(rows.find((row) => row.type === "assistant")?.timing).toBeUndefined() }) + + it("omits turn timing when the turn ends on a tool call", () => { + const u1 = user("u1", { time: { created: 1_000 } }) + const a1 = assistant("a1", "u1", { finish: "tool-calls", time: { created: 1_500, completed: 3_500 } }) + const rows = transcriptRows(messageTurns([u1, a1]), lookup({ a1: [part("p1", "a1")] })) + + expect(rows.find((row) => row.type === "assistant")?.timing).toBeUndefined() + }) }) describe("retainTurn", () => { diff --git a/packages/kilo-vscode/webview-ui/src/context/transcript-rows.ts b/packages/kilo-vscode/webview-ui/src/context/transcript-rows.ts index 14010c2e2a6..6cf6696aca4 100644 --- a/packages/kilo-vscode/webview-ui/src/context/transcript-rows.ts +++ b/packages/kilo-vscode/webview-ui/src/context/transcript-rows.ts @@ -125,18 +125,35 @@ function diffs(msg: Message) { /** * Finish time and duration for a settled turn, derived the same way as the * TUI session view: the last assistant message's completion time minus the - * user prompt's creation time. Partial (not-yet-parented) turns are skipped - * because their synthetic user message reuses an assistant timestamp. + * user prompt's creation time. Mirrors the TUI's finish guard, because + * `time.completed` is also set on `tool-calls` steps, and skips partial + * (not-yet-parented) turns whose synthetic user message reuses an assistant + * timestamp. */ function turnTiming(turn: MessageTurn) { if (turn.partial) return undefined - const end = turn.assistant.at(-1)?.time?.completed + const last = turn.assistant.at(-1) + if (!last?.finish || last.finish === "tool-calls" || last.finish === "unknown") return undefined + const end = last.time?.completed if (typeof end !== "number") return undefined const start = turn.user.time?.created if (typeof start !== "number") return { completedAt: end } return { completedAt: end, durationMs: Math.max(0, end - start) } } +/** + * The row whose action row carries the copy button. Turn timing renders inside + * that action row, so it must attach here rather than to the turn's last row: + * the copy part can sit in an earlier chunk, and a tool-only or empty trailing + * message has no action row at all. + */ +function copyOwner(rows: TranscriptRow[], copied?: string) { + if (!copied) return undefined + return rows.find( + (row): row is TranscriptAssistantRow => row.type === "assistant" && row.parts.some((part) => part.id === copied), + ) +} + function content(parts: Part[]) { const text = parts.find((part) => part.type === "text" && !part.synthetic) if (text?.type === "text" && text.text.trim()) return true @@ -194,37 +211,35 @@ export function transcriptRows( }) } - let tail: TranscriptAssistantRow | undefined for (const msg of turn.assistant) { const visible = parts(msg.id) if (visible.length === 0) { - tail = { + rows.push({ ...meta, type: "assistant", key: `${turn.id}:assistant:${msg.id}:empty`, message: msg, parts: visible, copy: copied, - } - rows.push(tail) + }) continue } for (let start = 0; start < visible.length; start += size) { const chunk = visible.slice(start, start + size) - tail = { + rows.push({ ...meta, type: "assistant", key: `${turn.id}:assistant:${msg.id}:${chunk[0]!.id}`, message: msg, parts: chunk, copy: copied, - } - rows.push(tail) + }) } } const timing = turnTiming(turn) - if (tail && timing) tail.timing = timing + const owner = copyOwner(rows, copied) + if (owner && timing) owner.timing = timing const changes = diffs(turn.user) if (changes.length > 0) { From 5a73a454374a41bf0366189be1131cdb274d53ae Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 15 Sep 2026 11:46:10 +0200 Subject: [PATCH 4/5] perf(vscode): scope the turn timing lookup to the current turn The copy-row lookup walked every row built so far once per turn, which is O(rows^2) inside a single transcriptRows call and runs on every streamed part update. Collect the current turn's assistant rows and search only those, and skip the lookup entirely when there is no timing to attach. --- .../webview-ui/src/context/transcript-rows.ts | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/src/context/transcript-rows.ts b/packages/kilo-vscode/webview-ui/src/context/transcript-rows.ts index 6cf6696aca4..ca1271999ae 100644 --- a/packages/kilo-vscode/webview-ui/src/context/transcript-rows.ts +++ b/packages/kilo-vscode/webview-ui/src/context/transcript-rows.ts @@ -142,16 +142,17 @@ function turnTiming(turn: MessageTurn) { } /** - * The row whose action row carries the copy button. Turn timing renders inside - * that action row, so it must attach here rather than to the turn's last row: - * the copy part can sit in an earlier chunk, and a tool-only or empty trailing - * message has no action row at all. + * Attach turn timing to the row whose action row carries the copy button, + * because that is where it renders rather than on the turn's last row: the + * copy part can sit in an earlier chunk, and a tool-only or empty trailing + * message has no action row at all. Only the current turn's assistant rows are + * scanned, and the lookup is skipped when there is no timing to show, so the + * per-part streaming path stays cheap. */ -function copyOwner(rows: TranscriptRow[], copied?: string) { - if (!copied) return undefined - return rows.find( - (row): row is TranscriptAssistantRow => row.type === "assistant" && row.parts.some((part) => part.id === copied), - ) +function attachTiming(rows: TranscriptAssistantRow[], copied: string | undefined, timing: TurnTiming | undefined) { + if (!timing || !copied) return + const owner = rows.find((row) => row.parts.some((part) => part.id === copied)) + if (owner) owner.timing = timing } function content(parts: Part[]) { @@ -211,6 +212,7 @@ export function transcriptRows( }) } + const assistant: TranscriptAssistantRow[] = [] for (const msg of turn.assistant) { const visible = parts(msg.id) if (visible.length === 0) { @@ -226,20 +228,20 @@ export function transcriptRows( } for (let start = 0; start < visible.length; start += size) { const chunk = visible.slice(start, start + size) - rows.push({ + const row: TranscriptAssistantRow = { ...meta, type: "assistant", key: `${turn.id}:assistant:${msg.id}:${chunk[0]!.id}`, message: msg, parts: chunk, copy: copied, - }) + } + assistant.push(row) + rows.push(row) } } - const timing = turnTiming(turn) - const owner = copyOwner(rows, copied) - if (owner && timing) owner.timing = timing + attachTiming(assistant, copied, turnTiming(turn)) const changes = diffs(turn.user) if (changes.length > 0) { From eff45afaf6803bdb9efddd922d9a8da222cf352e Mon Sep 17 00:00:00 2001 From: "kilo-maintainer[bot]" Date: Tue, 15 Sep 2026 11:07:59 +0000 Subject: [PATCH 5/5] chore: update kilo-vscode visual regression baselines --- .../chat/prompt-rail-sidebar-chromium-linux.png | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-sidebar-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-sidebar-chromium-linux.png index 60417118d0c..9c7317c4e34 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-sidebar-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-sidebar-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:99d02dae9682fc929d2a34f05feca309237e957d634cea07d3ef80dec983d865 -size 26036 +oid sha256:1e81dc5c2df088319fbe25fcb33b45680d03235f7ab7e8753f5c276eed140d70 +size 27128