+
+
+ {/* Queued controls live in the same reserved action row as the
+ hover actions, so unqueueing swaps content without a height change. */}
+
-
-
-
+
+
@@ -1002,23 +1007,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..a398c038f62
--- /dev/null
+++ b/packages/kilo-vscode/tests/unit/message-time.test.ts
@@ -0,0 +1,42 @@
+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", () => {
+ const at = Date.UTC(2026, 0, 1, 13, 5)
+ // 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/)
+ })
+
+ 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..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", () => {
@@ -270,6 +278,58 @@ describe("transcriptRows", () => {
expect(live[0]).not.toBe(second[0])
expect(live[1]).not.toBe(second[1])
})
+
+ 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", { 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({
+ timing: { completedAt: 3_500, durationMs: 2_500 },
+ })
+ })
+
+ 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 } })
+ const rows = transcriptRows(messageTurns([u1, a1]), lookup({ a1: [part("p1", "a1")] }))
+
+ 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/components/chat/AssistantMessage.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx
index dd714d47b66..b5824b0f31c 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
@@ -320,6 +325,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 45ef07287df..e2bd411ca4e 100644
--- a/packages/kilo-vscode/webview-ui/src/components/chat/TranscriptRow.tsx
+++ b/packages/kilo-vscode/webview-ui/src/components/chat/TranscriptRow.tsx
@@ -108,6 +108,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..ca1271999ae 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,39 @@ 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. 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 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) }
+}
+
+/**
+ * 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 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[]) {
const text = parts.find((part) => part.type === "text" && !part.synthetic)
if (text?.type === "text" && text.text.trim()) return true
@@ -165,6 +212,7 @@ export function transcriptRows(
})
}
+ const assistant: TranscriptAssistantRow[] = []
for (const msg of turn.assistant) {
const visible = parts(msg.id)
if (visible.length === 0) {
@@ -180,17 +228,21 @@ 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)
}
}
+ attachTiming(assistant, copied, turnTiming(turn))
+
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 dd69513b203..b15ce4760d4 100644
--- a/packages/kilo-vscode/webview-ui/src/styles/chat-layout.css
+++ b/packages/kilo-vscode/webview-ui/src/styles/chat-layout.css
@@ -469,11 +469,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`
+}