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-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 diff --git a/packages/kilo-ui/src/components/message-part.css b/packages/kilo-ui/src/components/message-part.css index c1c6670204d..b125841e759 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,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; } } diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx index a41d13ad2f6..9a02487741d 100644 --- a/packages/kilo-ui/src/components/message-part.tsx +++ b/packages/kilo-ui/src/components/message-part.tsx @@ -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: { - + + + {/* 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` +}