Merge pull request #14155 from Kilo-Org/fascinated-baryonyx

feat(vscode): show chat message timestamps on hover
This commit is contained in:
Marius
2026-09-15 15:42:51 +02:00
committed by GitHub
12 changed files with 293 additions and 39 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Show message timestamps on hover in the chat transcript, including the turn's finish time and duration.
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:99d02dae9682fc929d2a34f05feca309237e957d634cea07d3ef80dec983d865
size 26036
oid sha256:1e81dc5c2df088319fbe25fcb33b45680d03235f7ab7e8753f5c276eed140d70
size 27128
@@ -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,17 @@ 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 and interactive without hover. */
[data-slot="user-message-copy-wrapper"][data-queued] {
opacity: 1;
pointer-events: auto;
}
}
body.vscode-high-contrast [data-component="user-message"] [data-slot="user-message-text"],
@@ -458,7 +484,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;
}
}
@@ -177,6 +177,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
}
@@ -816,11 +819,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(() => {
@@ -942,17 +941,23 @@ export function UserMessageDisplay(props: {
<HighlightedText text={text()} references={inlineFiles()} agents={agents()} />
</div>
</Show>
<GrowBox animate={!!props.animate} open={!!props.queued}>
</div>
{/* Queued controls live in the same reserved action row as the
hover actions, so unqueueing swaps content without a height change. */}
<div
data-slot="user-message-copy-wrapper"
data-interrupted={props.interrupted ? "" : undefined}
data-queued={props.queued ? "" : undefined}
>
<Show when={props.queued}>
<div data-slot="user-message-queued-indicator">
<TextShimmer text={i18n.t("ui.message.queued")} />
<Edit />
<Delete />
</div>
</GrowBox>
</div>
<div data-slot="user-message-copy-wrapper" data-interrupted={props.interrupted ? "" : undefined}>
<Show when={metaHead() || metaTail()}>
</Show>
<Show when={!props.queued && (metaHead() || metaTail())}>
<span data-slot="user-message-meta-wrap">
<Show when={metaHead()}>
<span data-slot="user-message-meta" class="text-12-regular text-text-weak cursor-default">
@@ -1002,23 +1007,25 @@ export function UserMessageDisplay(props: {
/>
</Tooltip>
</Show>
<Tooltip
value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")}
placement="right"
gutter={4}
>
<IconButton
icon={copied() ? "check" : "copy"}
size="normal"
variant="ghost"
onMouseDown={(e) => e.preventDefault()}
onClick={(event) => {
event.stopPropagation()
handleCopy()
}}
aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")}
/>
</Tooltip>
<Show when={!props.queued}>
<Tooltip
value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")}
placement="right"
gutter={4}
>
<IconButton
icon={copied() ? "check" : "copy"}
size="normal"
variant="ghost"
onMouseDown={(e) => e.preventDefault()}
onClick={(event) => {
event.stopPropagation()
handleCopy()
}}
aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")}
/>
</Tooltip>
</Show>
</div>
</>
</Show>
@@ -1093,6 +1100,7 @@ export function Part(props: MessagePartProps) {
working={props.working}
feedback={props.feedback}
throughput={props.throughput}
turnMeta={props.turnMeta}
readonly={props.readonly}
/>
</Show>
@@ -1826,6 +1834,13 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
</Tooltip>
</Show>
<Show when={props.throughput}>{(el) => <span data-slot="assistant-throughput-inline">{el()}</span>}</Show>
<Show when={props.turnMeta}>
{(el) => (
<span data-slot="assistant-turn-meta" class="cursor-default">
{el()}
</span>
)}
</Show>
</div>
</Show>
<Show when={summary()}>
@@ -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")
})
})
@@ -18,6 +18,14 @@ const assistant = (id: string, parentID: string, opts: Partial<Message> = {}): 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<string, Part[]>) => (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", () => {
@@ -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<AssistantMessageProps> = (props) => {
return <ThroughputBadge metrics={metrics} />
})
// 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<JSX.Element | undefined>(() => {
const timing = props.timing
if (!timing) return undefined
if (part.id !== props.showAssistantCopyPartID) return undefined
return (
<span data-component="message-time">
{formatClock(timing.completedAt, language.locale())}
<Show when={timing.durationMs}>
{(ms) => <span data-slot="message-time-duration"> · {formatDuration(ms())}</span>}
</Show>
</span>
)
})
return (
<Show
when={
@@ -367,6 +390,7 @@ export const AssistantMessage: Component<AssistantMessageProps> = (props) => {
settled={settled()}
feedback={props.feedback}
throughput={throughputEl()}
turnMeta={turnMetaEl()}
readonly={props.readonly}
animate={
part.type === "tool" &&
@@ -108,6 +108,7 @@ export const TranscriptRowView: Component<TranscriptRowViewProps> = (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}
@@ -217,7 +217,9 @@ export const LanguageProvider: ParentComponent<LanguageProviderProps> = (props)
<LanguageContext.Provider
value={{ locale, setLocale, userOverride, t: t as (key: string, params?: UiI18nParams) => string }}
>
<I18nProvider value={{ locale: () => locale(), t, plural }}>{props.children}</I18nProvider>
{/* 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). */}
<I18nProvider value={{ locale: () => localeToBcp47(locale()), t, plural }}>{props.children}</I18nProvider>
</LanguageContext.Provider>
)
}
@@ -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 })
@@ -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);
@@ -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`
}